Terra UI is NASA’s reusable web-component library for geospatial UIs, published to PyPI as terra_ui_components. It builds on anywidget, which means in principle it should slot straight into the static-export pipeline used by every other notebook on this site.
This notebook is two things at once:
A smoke test: does an external third-party anywidget package round-trip cleanly through
myst build? (Spoiler: yes, after a few runtime patches we apply in the setup cell — see the next section.)A small interactive demo: a
TerraDateRangeSliderdriving a lonboard map of ten years of M5+ earthquakes from the USGS FDSN event service — roughly 15k events, no API key, no auth, just public seismology data and the widgets we already have. The slider→map filter runs entirely client-side vialonboard’sDataFilterExtension, so it works the same in JupyterLab and on this static page (no kernel needed). Click any point for a side-panel popup with the full event record, or hover for a quick tooltip.
Things deliberately not in this notebook: a Terra UI chart of the magnitudes — TerraTimeSeries is hardwired to fetch from NASA’s catalog and can’t be driven by externally supplied data; see docs/upstream-terra-ui-data-flow.md for the full set of upstream issues we ran into.
Setup: shadow-DOM-friendly Terra UI loader¶
Terra UI’s stock _esm injects an autoloader that watches document.documentElement with a MutationObserver and lazy-loads each component when it sees the corresponding <terra-*> tag appear. That works in JupyterLab, where widgets render directly into the page. It does not work for our static-export pipeline — @myst-theme/anywidget mounts each widget in its own shadow root, and the autoloader’s observer is blind to nodes added inside shadow DOMs, so the custom elements never get upgraded.
The cell below patches every TerraBaseWidget subclass’s _esm to (a) inject the Horizon CSS once and (b) eagerly import the full component bundle, with a top-level await so the wrapper module only resolves after every <terra-*> is registered. This is a one-line behavioural change — no API surface differences for the rest of the notebook.
import terra_ui_components
from terra_ui_components import TerraBaseWidget, TerraDateRangeSlider
_TERRA_VERSION = "0.0.178"
_EAGER_LOADER = f"""
if (!document.head.querySelector('link[data-terra-css]')) {{
const css = document.createElement('link');
css.rel = 'stylesheet';
css.href = 'https://cdn.jsdelivr.net/npm/@nasa-terra/components@{_TERRA_VERSION}/cdn/themes/horizon.css';
css.dataset.terraCss = '1';
document.head.appendChild(css);
}}
const __terraBundle = (window.__terraBundle = window.__terraBundle
|| import('https://cdn.jsdelivr.net/npm/@nasa-terra/components@{_TERRA_VERSION}/cdn/terra-ui-components.js'));
await __terraBundle;
"""
_AUTOLOADER = TerraBaseWidget.get_autoloader()
_patched = []
for _name in dir(terra_ui_components):
_cls = getattr(terra_ui_components, _name)
if isinstance(_cls, type) and issubclass(_cls, TerraBaseWidget) and _cls is not TerraBaseWidget:
if isinstance(_cls._esm, str) and _cls._esm.startswith(_AUTOLOADER):
_cls._esm = _EAGER_LOADER + _cls._esm[len(_AUTOLOADER):]
_patched.append(_name)
print(f"Patched {len(_patched)} Terra UI widget classes for eager loading.")
# TerraDateRangeSlider's wrapper has a placeholder `terra-date-range-change`
# listener whose body is entirely commented out, so user drags don't sync
# back to Python and Python observers never fire. Replace it with a real
# implementation that pushes startDate/endDate from the component back to
# the model. Tracked for upstream PR in docs/upstream-terra-ui-data-flow.md.
_dr_anchor = (
"component.addEventListener('terra-date-range-change', (e) => {\n"
" // Placeholder for event handling, you'll need to provide your own functionality here\n"
" // model.set('terra-date-range-change_triggered', true)\n"
" // model.save_changes()\n"
" })"
)
_dr_patched = (
"component.addEventListener('terra-date-range-change', (e) => {\n"
" const start = e?.detail?.startDate ?? component.startDate;\n"
" const end = e?.detail?.endDate ?? component.endDate;\n"
" if (start !== undefined && start !== null) model.set('startDate', start);\n"
" if (end !== undefined && end !== null) model.set('endDate', end);\n"
" model.save_changes();\n"
" })"
)
assert TerraDateRangeSlider._esm.count(_dr_anchor) == 1, "TerraDateRangeSlider placeholder anchor not found"
TerraDateRangeSlider._esm = TerraDateRangeSlider._esm.replace(_dr_anchor, _dr_patched, 1)
print("Patched TerraDateRangeSlider to sync startDate/endDate back to Python on user drag.")Patched 15 Terra UI widget classes for eager loading.
Patched TerraDateRangeSlider to sync startDate/endDate back to Python on user drag.
Smoke test: TerraInput¶
The simplest Terra UI component exposed by the Python wrapper. With the patch above applied, instantiating it triggers a full-bundle import in the browser and the <terra-input> element gets upgraded as soon as the wrapper module’s top-level await resolves.
from terra_ui_components import TerraInput
TerraInput(attr="placeholder='Type here…' label='Smoke test'")Earthquakes, last ten years¶
Query the USGS FDSN event service for every M5+ event in the last decade — roughly 15k records — and shape it into a DataFrame. No auth, no API key. Re-running the notebook picks up whatever the service currently returns; the demo is intentionally not pinned to a snapshot.
import urllib.request
import urllib.parse
import json
import datetime as dt
import pandas as pd
end = dt.datetime.now(dt.UTC).date()
start = end - dt.timedelta(days=365 * 10)
params = {
"format": "geojson",
"starttime": start.isoformat(),
"endtime": end.isoformat(),
"minmagnitude": 5.0,
"orderby": "time",
}
USGS_URL = "https://earthquake.usgs.gov/fdsnws/event/1/query?" + urllib.parse.urlencode(params)
with urllib.request.urlopen(USGS_URL) as r:
raw = json.load(r)
rows = []
for f in raw["features"]:
p = f["properties"]
if p.get("mag") is None:
continue
rows.append({
"time": dt.datetime.fromtimestamp(p["time"] / 1000, dt.UTC).replace(tzinfo=None),
"mag": p["mag"],
"magType": p.get("magType") or "",
"depth_km": f["geometry"]["coordinates"][2],
"lon": f["geometry"]["coordinates"][0],
"lat": f["geometry"]["coordinates"][1],
"place": p.get("place") or "",
"felt": p.get("felt") or 0,
"tsunami": p.get("tsunami") or 0,
"sig": p.get("sig") or 0,
"url": p.get("url") or "",
})
eq_df = pd.DataFrame(rows).sort_values("time").reset_index(drop=True)
print(f"{len(eq_df)} events from {eq_df.time.min():%Y-%m-%d} to {eq_df.time.max():%Y-%m-%d}, mag {eq_df.mag.min()}–{eq_df.mag.max()}")
eq_df.head()17677 events from 2016-05-14 to 2026-05-11, mag 5.0–8.8
Slider drives map¶
A TerraDateRangeSlider paired with a lonboard ScatterplotLayer that uses deck.gl’s DataFilterExtension to range-filter on the GPU. Each event’s timestamp (in seconds-since-epoch) is shipped once via get_filter_value; updating layer.filter_range = [start_s, end_s] then redraws on the GPU shader without rebuilding the layer.
Points are coloured by depth band — four discrete buckets (shallow ≤ 50 km up to very deep > 300 km), each with one colour from the inferno_r ramp at the band’s centre depth — and sized by magnitude². Below the map, an EqDashboard panel shows live aggregate stats (count visible, magnitude / depth means, tsunami count) and a depth-band legend with toggles. Tick a band off and its points vanish from the map; the stats recompute. Drag the slider and everything updates in lockstep. All of it runs entirely client-side, no kernel attached.
Hover any point for a tooltip with the full event record, or click for a side-panel popup with the same fields plus a link out to the USGS event page.
The wiring is widgets.date_range_filter_binder.DateRangeFilterBinder (slider → filter_range) and widgets.eq_dashboard.EqDashboard (depth checkboxes → filter_categories and aggregate compute). Both poll the live element / model state on a 100 ms loop rather than relying on Python observers — the same code path drives the map in JupyterLab and on this static page.
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path().absolute().parent))
from terra_ui_components import TerraDateRangeSlider
import lonboard
from lonboard import ScatterplotLayer
from lonboard.layer_extension import DataFilterExtension
import geopandas as gpd
import numpy as np
from matplotlib import colormaps
from shapely.geometry import Point
from IPython.display import display
from widgets.date_range_filter_binder import DateRangeFilterBinder
from widgets.eq_dashboard import EqDashboard
gdf = gpd.GeoDataFrame(
eq_df,
geometry=[Point(xy) for xy in zip(eq_df.lon, eq_df.lat)],
crs="EPSG:4326",
)
# Per-point timestamp in seconds-since-epoch. Use datetime64[s] cast so we
# get seconds regardless of the column's stored resolution (modern pandas
# uses datetime64[us] by default, so a naive .astype('int64') // 10**9 is
# off by 1000×). Seconds (~1.78e9) are well within float32's exact-integer
# range — DataFilterExtension compares as float32 in the shader.
filter_values = (
eq_df.time.astype("datetime64[s]").astype("int64").to_numpy().astype(np.float64)
)
min_ts = float(filter_values.min())
max_ts = float(filter_values.max()) + 86400 # +1 day so the "all" view includes the last day
# Depth bands — four discrete buckets, ranges chosen to match common
# seismology vocabulary (shallow / intermediate / deep crustal / very deep
# slab). Edges are inclusive on the lower bound.
depth_band_edges = np.array([0.0, 50.0, 100.0, 300.0, 800.0])
depth_band_labels = ["Shallow", "Intermediate", "Deep", "Very deep"]
# np.digitize with the interior edges gives a 0-indexed band per row.
depth_clipped = np.clip(eq_df.depth_km.to_numpy(), 0.0, 800.0 - 1e-6)
depth_band = np.digitize(depth_clipped, depth_band_edges[1:-1]).astype(np.uint8)
# One colour per band, sampled from inferno_r at each band's geometric-mean
# depth on a log scale (matches the previous continuous ramp's intent: hot
# at the surface, dark at depth). band_colors is (N_bands, 4) uint8.
_centers_km = np.sqrt(
np.maximum(depth_band_edges[:-1], 1.0) * depth_band_edges[1:]
)
_centers_norm = np.log10(np.clip(_centers_km, 1.0, 700.0)) / np.log10(700.0)
band_colors = (colormaps["inferno_r"](_centers_norm) * 255).astype(np.uint8)
band_colors[:, 3] = 200
# Per-row fill colour by lookup — one of N_bands distinct values.
fill_rgba = band_colors[depth_band]
layer = ScatterplotLayer.from_geopandas(
gdf,
get_radius=eq_df.mag.astype(float).to_numpy() ** 2 * 1500.0,
radius_units="meters",
radius_min_pixels=2,
radius_max_pixels=20,
get_fill_color=fill_rgba,
extensions=[DataFilterExtension(filter_size=1, category_size=1)],
get_filter_value=filter_values,
filter_range=(min_ts, max_ts),
get_filter_category=depth_band,
filter_categories=[0, 1, 2, 3],
)
m = lonboard.Map(layer, show_tooltip=True)
slider = TerraDateRangeSlider(
minDate=eq_df.time.min().strftime("%Y-%m-%d"),
maxDate=eq_df.time.max().strftime("%Y-%m-%d"),
startDate=eq_df.time.min().strftime("%Y-%m-%d"),
endDate=eq_df.time.max().strftime("%Y-%m-%d"),
)
binder = DateRangeFilterBinder(slider=slider, layer=layer)
panel = EqDashboard(
layer=layer,
mag=eq_df.mag.astype(np.float32).to_numpy().tobytes(),
depth_km=eq_df.depth_km.astype(np.float32).to_numpy().tobytes(),
depth_band=depth_band.tobytes(),
tsunami=eq_df.tsunami.astype(np.uint8).to_numpy().tobytes(),
filter_value=filter_values.tobytes(),
n_total=int(len(eq_df)),
bands=[
{
"index": int(i),
"label": depth_band_labels[i],
"color": band_colors[i].tolist(),
"min_km": float(depth_band_edges[i]),
"max_km": float(depth_band_edges[i + 1]),
}
for i in range(len(depth_band_labels))
],
)
# Emit each widget as its own cell output so the static-export plugin
# treats them as independent anywidget nodes (a VBox would only export the
# first descendant).
display(slider)
display(m)
display(panel)
display(binder)