Skip to content

Commit 1ff5780

Browse files
authored
Add support for TiTiler-CMR (#1289)
1 parent 01844f8 commit 1ff5780

File tree

8 files changed

+1570
-6
lines changed

8 files changed

+1570
-6
lines changed

docs/notebooks/113_titiler_cmr.ipynb

Lines changed: 445 additions & 0 deletions
Large diffs are not rendered by default.

leafmap/common.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9110,6 +9110,31 @@ def arc_add_layer(url, name=None, shown=True, opacity=1.0):
91109110
layer.transparency = 100 - (opacity * 100)
91119111

91129112

9113+
def is_global_bounds(bounds, tolerance=1.0):
9114+
"""Check if bounds represent global extent.
9115+
9116+
Args:
9117+
bounds (list): Bounding box as [minx, miny, maxx, maxy].
9118+
tolerance (float): Tolerance for comparing bounds to global extent.
9119+
Defaults to 1.0 degree.
9120+
9121+
Returns:
9122+
bool: True if bounds are approximately global [-180, -90, 180, 90].
9123+
"""
9124+
if bounds is None or len(bounds) != 4:
9125+
return False
9126+
9127+
minx, miny, maxx, maxy = bounds
9128+
global_bounds = [-180.0, -90.0, 180.0, 90.0]
9129+
9130+
return (
9131+
abs(minx - global_bounds[0]) <= tolerance
9132+
and abs(miny - global_bounds[1]) <= tolerance
9133+
and abs(maxx - global_bounds[2]) <= tolerance
9134+
and abs(maxy - global_bounds[3]) <= tolerance
9135+
)
9136+
9137+
91139138
def arc_zoom_to_extent(xmin, ymin, xmax, ymax):
91149139
"""Zoom to an extent in ArcGIS Pro.
91159140

leafmap/foliumap.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,113 @@ def add_stac_layer(
17091709
self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]])
17101710
common.arc_zoom_to_extent(bounds[0], bounds[1], bounds[2], bounds[3])
17111711

1712+
def add_cmr_layer(
1713+
self,
1714+
concept_id: str,
1715+
datetime: Optional[str] = None,
1716+
backend: str = "rasterio",
1717+
variable: Optional[str] = None,
1718+
bands: Optional[Union[str, List[str]]] = None,
1719+
bands_regex: Optional[str] = None,
1720+
expression: Optional[str] = None,
1721+
name: Optional[str] = "CMR Layer",
1722+
attribution: Optional[str] = "NASA Earthdata",
1723+
opacity: Optional[float] = 1.0,
1724+
shown: Optional[bool] = True,
1725+
rescale: Optional[Union[str, List]] = None,
1726+
colormap_name: Optional[str] = None,
1727+
color_formula: Optional[str] = None,
1728+
titiler_cmr_endpoint: Optional[str] = None,
1729+
zoom_to_layer: Optional[bool] = True,
1730+
**kwargs,
1731+
) -> None:
1732+
"""Adds a NASA Earthdata CMR layer to the map using TiTiler CMR.
1733+
1734+
This method allows you to visualize NASA Earthdata collections directly
1735+
on the map using the TiTiler CMR endpoint.
1736+
1737+
Args:
1738+
concept_id (str): NASA CMR collection concept ID (e.g., 'C2036881735-POCLOUD').
1739+
datetime (str, optional): RFC3339 datetime or range (e.g., '2024-01-15' or
1740+
'2024-01-01/2024-01-31'). Defaults to None.
1741+
backend (str, optional): Backend to use - 'rasterio' for COGs or 'xarray' for
1742+
NetCDF/Zarr. Defaults to 'rasterio'.
1743+
variable (str, optional): Variable name for xarray backend datasets. Required
1744+
when using backend='xarray'.
1745+
bands (str | list, optional): Band name(s) for rasterio backend.
1746+
bands_regex (str, optional): Regex pattern for selecting bands (e.g., 'B[0-9][0-9]').
1747+
expression (str, optional): Band math expression (e.g., '(B05-B04)/(B05+B04)').
1748+
name (str, optional): The layer name. Defaults to 'CMR Layer'.
1749+
attribution (str, optional): Attribution text. Defaults to 'NASA Earthdata'.
1750+
opacity (float, optional): Layer opacity (0.0 to 1.0). Defaults to 1.0.
1751+
shown (bool, optional): Whether the layer is visible. Defaults to True.
1752+
rescale (str | list, optional): Min/max values for rescaling (e.g., '270,305'
1753+
or [[270, 305]]).
1754+
colormap_name (str, optional): Name of colormap (e.g., 'thermal', 'viridis').
1755+
color_formula (str, optional): Color formula (e.g., 'Gamma RGB 3.5 Saturation 1.7').
1756+
titiler_cmr_endpoint (str, optional): TiTiler CMR endpoint URL.
1757+
zoom_to_layer (bool, optional): Whether to zoom to the layer extent. Defaults to True.
1758+
**kwargs: Additional arguments passed to the TiTiler CMR endpoint.
1759+
1760+
Examples:
1761+
>>> import leafmap.foliumap as leafmap
1762+
>>> m = leafmap.Map()
1763+
>>> m.add_cmr_layer(
1764+
... concept_id="C2036881735-POCLOUD",
1765+
... datetime="2024-01-15",
1766+
... backend="xarray",
1767+
... variable="analysed_sst",
1768+
... rescale="270,305",
1769+
... colormap_name="thermal",
1770+
... name="Sea Surface Temperature"
1771+
... )
1772+
"""
1773+
from .stac import cmr_tile, cmr_bounds
1774+
1775+
if os.environ.get("USE_MKDOCS") is not None:
1776+
return
1777+
1778+
tile_url = cmr_tile(
1779+
concept_id=concept_id,
1780+
datetime=datetime,
1781+
backend=backend,
1782+
variable=variable,
1783+
bands=bands,
1784+
bands_regex=bands_regex,
1785+
expression=expression,
1786+
rescale=rescale,
1787+
colormap_name=colormap_name,
1788+
color_formula=color_formula,
1789+
titiler_cmr_endpoint=titiler_cmr_endpoint,
1790+
**kwargs,
1791+
)
1792+
1793+
if tile_url is None:
1794+
print("Failed to get CMR tile URL")
1795+
return
1796+
1797+
self.add_tile_layer(
1798+
url=tile_url,
1799+
name=name,
1800+
attribution=attribution,
1801+
opacity=opacity,
1802+
shown=shown,
1803+
)
1804+
1805+
if zoom_to_layer:
1806+
bounds = cmr_bounds(
1807+
concept_id=concept_id,
1808+
datetime=datetime,
1809+
backend=backend,
1810+
variable=variable,
1811+
titiler_cmr_endpoint=titiler_cmr_endpoint,
1812+
)
1813+
# Skip zooming if bounds are global (TiTiler CMR returns global bounds
1814+
# when actual data bounds are not available)
1815+
if bounds is not None and not common.is_global_bounds(bounds):
1816+
self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]])
1817+
common.arc_zoom_to_extent(bounds[0], bounds[1], bounds[2], bounds[3])
1818+
17121819
def add_mosaic_layer(
17131820
self,
17141821
url: str,

leafmap/leafmap.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,6 +1237,235 @@ def add_stac_layer(
12371237

12381238
self.cog_layer_dict[name] = params
12391239

1240+
def add_cmr_layer(
1241+
self,
1242+
concept_id: str,
1243+
datetime: Optional[str] = None,
1244+
backend: str = "rasterio",
1245+
variable: Optional[str] = None,
1246+
bands: Optional[Union[str, List[str]]] = None,
1247+
bands_regex: Optional[str] = None,
1248+
expression: Optional[str] = None,
1249+
name: str = "CMR Layer",
1250+
attribution: str = "NASA Earthdata",
1251+
opacity: float = 1.0,
1252+
shown: bool = True,
1253+
rescale: Optional[Union[str, List]] = None,
1254+
colormap_name: Optional[str] = None,
1255+
color_formula: Optional[str] = None,
1256+
titiler_cmr_endpoint: Optional[str] = None,
1257+
zoom_to_layer: bool = True,
1258+
layer_index: Optional[int] = None,
1259+
**kwargs,
1260+
) -> None:
1261+
"""Adds a NASA Earthdata CMR layer to the map using TiTiler CMR.
1262+
1263+
This method allows you to visualize NASA Earthdata collections directly
1264+
on the map using the TiTiler CMR endpoint.
1265+
1266+
Args:
1267+
concept_id (str): NASA CMR collection concept ID (e.g., 'C2036881735-POCLOUD').
1268+
datetime (str, optional): RFC3339 datetime or range (e.g., '2024-01-15' or
1269+
'2024-01-01/2024-01-31'). Defaults to None.
1270+
backend (str, optional): Backend to use - 'rasterio' for COGs or 'xarray' for
1271+
NetCDF/Zarr. Defaults to 'rasterio'.
1272+
variable (str, optional): Variable name for xarray backend datasets. Required
1273+
when using backend='xarray'.
1274+
bands (str | list, optional): Band name(s) for rasterio backend.
1275+
bands_regex (str, optional): Regex pattern for selecting bands (e.g., 'B[0-9][0-9]').
1276+
expression (str, optional): Band math expression (e.g., '(B05-B04)/(B05+B04)').
1277+
name (str, optional): The layer name. Defaults to 'CMR Layer'.
1278+
attribution (str, optional): Attribution text. Defaults to 'NASA Earthdata'.
1279+
opacity (float, optional): Layer opacity (0.0 to 1.0). Defaults to 1.0.
1280+
shown (bool, optional): Whether the layer is visible. Defaults to True.
1281+
rescale (str | list, optional): Min/max values for rescaling (e.g., '270,305'
1282+
or [[270, 305]]).
1283+
colormap_name (str, optional): Name of colormap (e.g., 'thermal', 'viridis').
1284+
color_formula (str, optional): Color formula (e.g., 'Gamma RGB 3.5 Saturation 1.7').
1285+
titiler_cmr_endpoint (str, optional): TiTiler CMR endpoint URL.
1286+
zoom_to_layer (bool, optional): Whether to zoom to the layer extent. Defaults to True.
1287+
layer_index (int, optional): Index to insert the layer. Defaults to None.
1288+
**kwargs: Additional arguments passed to the TiTiler CMR endpoint.
1289+
1290+
Examples:
1291+
>>> # Sea Surface Temperature using xarray backend
1292+
>>> m = leafmap.Map()
1293+
>>> m.add_cmr_layer(
1294+
... concept_id="C2036881735-POCLOUD",
1295+
... datetime="2024-01-15",
1296+
... backend="xarray",
1297+
... variable="analysed_sst",
1298+
... rescale="270,305",
1299+
... colormap_name="thermal",
1300+
... name="Sea Surface Temperature"
1301+
... )
1302+
1303+
>>> # HLS Landsat using rasterio backend
1304+
>>> m.add_cmr_layer(
1305+
... concept_id="C2021957657-LPCLOUD",
1306+
... datetime="2024-06-20T00:00:00Z/2024-06-27T23:59:59Z",
1307+
... backend="rasterio",
1308+
... bands=["B04", "B03", "B02"],
1309+
... bands_regex="B[0-9][0-9]",
1310+
... color_formula="Gamma RGB 3.5 Saturation 1.7 Sigmoidal RGB 15 0.35",
1311+
... name="HLS Landsat"
1312+
... )
1313+
"""
1314+
from .stac import cmr_tile, cmr_bounds
1315+
1316+
if os.environ.get("USE_MKDOCS") is not None:
1317+
return
1318+
1319+
tile_url = cmr_tile(
1320+
concept_id=concept_id,
1321+
datetime=datetime,
1322+
backend=backend,
1323+
variable=variable,
1324+
bands=bands,
1325+
bands_regex=bands_regex,
1326+
expression=expression,
1327+
rescale=rescale,
1328+
colormap_name=colormap_name,
1329+
color_formula=color_formula,
1330+
titiler_cmr_endpoint=titiler_cmr_endpoint,
1331+
**kwargs,
1332+
)
1333+
1334+
if tile_url is None:
1335+
print("Failed to get CMR tile URL")
1336+
return
1337+
1338+
self.add_tile_layer(tile_url, name, attribution, opacity, shown, layer_index)
1339+
1340+
if zoom_to_layer:
1341+
bounds = cmr_bounds(
1342+
concept_id=concept_id,
1343+
datetime=datetime,
1344+
backend=backend,
1345+
variable=variable,
1346+
titiler_cmr_endpoint=titiler_cmr_endpoint,
1347+
)
1348+
# Skip zooming if bounds are global (TiTiler CMR returns global bounds
1349+
# when actual data bounds are not available)
1350+
if bounds is not None and not common.is_global_bounds(bounds):
1351+
self.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]])
1352+
common.arc_zoom_to_extent(bounds[0], bounds[1], bounds[2], bounds[3])
1353+
1354+
def add_cmr_timeseries(
1355+
self,
1356+
concept_id: str,
1357+
datetime: str,
1358+
step: str = "P1D",
1359+
temporal_mode: str = "point",
1360+
backend: str = "rasterio",
1361+
variable: Optional[str] = None,
1362+
bands: Optional[Union[str, List[str]]] = None,
1363+
bands_regex: Optional[str] = None,
1364+
expression: Optional[str] = None,
1365+
rescale: Optional[Union[str, List]] = None,
1366+
colormap_name: Optional[str] = None,
1367+
color_formula: Optional[str] = None,
1368+
name_prefix: str = "CMR",
1369+
attribution: str = "NASA Earthdata",
1370+
opacity: float = 1.0,
1371+
time_interval: int = 1,
1372+
position: str = "bottomright",
1373+
slider_length: str = "150px",
1374+
titiler_cmr_endpoint: Optional[str] = None,
1375+
**kwargs,
1376+
) -> None:
1377+
"""Adds a NASA Earthdata CMR time series layer with an interactive time slider.
1378+
1379+
This method creates multiple tile layers for different time steps and adds
1380+
a time slider control to navigate through the time series.
1381+
1382+
Args:
1383+
concept_id (str): NASA CMR collection concept ID (e.g., 'C2036881735-POCLOUD').
1384+
datetime (str): RFC3339 datetime range (e.g., '2024-01-01/2024-01-31').
1385+
step (str, optional): ISO 8601 duration for time steps (e.g., 'P1D' for 1 day,
1386+
'P1M' for 1 month, 'P2W' for 2 weeks). Defaults to 'P1D'.
1387+
temporal_mode (str, optional): Temporal mode - 'point' or 'range'. Defaults to 'point'.
1388+
backend (str, optional): Backend to use - 'rasterio' for COGs or 'xarray' for
1389+
NetCDF/Zarr. Defaults to 'rasterio'.
1390+
variable (str, optional): Variable name for xarray backend datasets.
1391+
bands (str | list, optional): Band name(s) for rasterio backend.
1392+
bands_regex (str, optional): Regex pattern for selecting bands (e.g., 'B[0-9][0-9]').
1393+
expression (str, optional): Band math expression (e.g., '(B05-B04)/(B05+B04)').
1394+
rescale (str | list, optional): Min/max values for rescaling (e.g., '270,305'
1395+
or [[270, 305]]).
1396+
colormap_name (str, optional): Name of colormap (e.g., 'thermal', 'viridis').
1397+
color_formula (str, optional): Color formula (e.g., 'Gamma RGB 3.5 Saturation 1.7').
1398+
name_prefix (str, optional): Prefix for layer names. Defaults to 'CMR'.
1399+
attribution (str, optional): Attribution text. Defaults to 'NASA Earthdata'.
1400+
opacity (float, optional): Layer opacity (0.0 to 1.0). Defaults to 1.0.
1401+
time_interval (int, optional): Time interval in seconds between frames. Defaults to 1.
1402+
position (str, optional): Position of the time slider ('topleft', 'topright',
1403+
'bottomleft', 'bottomright'). Defaults to 'bottomright'.
1404+
slider_length (str, optional): Length of the time slider. Defaults to '150px'.
1405+
titiler_cmr_endpoint (str, optional): TiTiler CMR endpoint URL.
1406+
**kwargs: Additional arguments passed to the TiTiler CMR endpoint.
1407+
1408+
Example:
1409+
>>> m = leafmap.Map()
1410+
>>> m.add_cmr_timeseries(
1411+
... concept_id="C2036881735-POCLOUD",
1412+
... datetime="2023-11-01/2024-10-30",
1413+
... step="P1M",
1414+
... backend="xarray",
1415+
... variable="sea_ice_fraction",
1416+
... colormap_name="blues_r",
1417+
... rescale="0,1"
1418+
... )
1419+
"""
1420+
from .stac import cmr_timeseries_tilejson
1421+
1422+
if os.environ.get("USE_MKDOCS") is not None:
1423+
return
1424+
1425+
tilejsons = cmr_timeseries_tilejson(
1426+
concept_id=concept_id,
1427+
datetime=datetime,
1428+
step=step,
1429+
temporal_mode=temporal_mode,
1430+
backend=backend,
1431+
variable=variable,
1432+
bands=bands,
1433+
bands_regex=bands_regex,
1434+
expression=expression,
1435+
rescale=rescale,
1436+
colormap_name=colormap_name,
1437+
color_formula=color_formula,
1438+
titiler_cmr_endpoint=titiler_cmr_endpoint,
1439+
**kwargs,
1440+
)
1441+
1442+
if tilejsons is None:
1443+
print("Failed to get CMR time series TileJSON")
1444+
return
1445+
1446+
layers_dict = {}
1447+
1448+
for dt_str, tilejson in tilejsons.items():
1449+
if "tiles" in tilejson:
1450+
tile_url = tilejson["tiles"][0]
1451+
# Format the datetime string for display (extract date portion)
1452+
if "T" in dt_str:
1453+
label = dt_str.split("T")[0]
1454+
else:
1455+
label = dt_str
1456+
layers_dict[label] = tile_url
1457+
1458+
if len(layers_dict) == 0:
1459+
print("No valid time series layers found")
1460+
return
1461+
1462+
self.add_time_slider(
1463+
layers=layers_dict,
1464+
time_interval=time_interval,
1465+
position=position,
1466+
slider_length=slider_length,
1467+
)
1468+
12401469
def add_zarr(
12411470
self,
12421471
url: str,

0 commit comments

Comments
 (0)