import copy
from typing import Any, Callable, Dict, List, Optional, Union
import branca # type: ignore
import folium # type: ignore
import folium.plugins # type: ignore
import numpy as np
from numpy.typing import NDArray
from stacmap.geojson import STACFeatureCollection
from stacmap.stac import get_items
from stacmap.types import GeoJSON, ItemContainer, ItemDict
from stacmap.utils import get_cmap
[docs]def explore(
stac: ItemContainer,
*,
name: Optional[str] = None,
bbox: Optional[List[float]] = None,
intersects: Optional[GeoJSON] = None,
thumbnails: bool = False,
prop: Optional[str] = None,
cmap: Optional[str] = None,
vmin: Optional[float] = None,
vmax: Optional[float] = None,
categorical: bool = False,
tooltip: bool = True,
popup: bool = False,
fields: Optional[List[str]] = None,
extensions: Optional[List[str]] = None,
shared_fields: bool = False,
add_id: bool = True,
m: Optional[folium.Map] = None,
width: Optional[int] = None,
height: Optional[int] = None,
tiles: str = "OpenStreetMap",
attr: Optional[str] = None,
zoom_to: bool = True,
legend: bool = True,
layer_control: bool = True,
control_scale: bool = True,
highlight: bool = True,
style_kwds: Dict[str, Any] = {},
highlight_kwds: Dict[str, Any] = {},
bounds_kwds: Dict[str, Any] = {},
popup_kwds: Dict[str, Any] = {},
tooltip_kwds: Dict[str, Any] = {},
map_kwds: Dict[str, Any] = {},
) -> folium.Map:
"""Explore STAC items through an interactive map.
Parameters
----------
stac : STAC item or collection
STAC items to explore.
name : str, optional
A name to assign to items in the layer control and legend. If no name is given, the
`collection` property of the first STAC item will be used.
bbox : list of floats
Bounding box coordinates (west, south, east, north) to overlay on the map.
intersects : dict
GeoJSON geometry to overlay on the map.
thumbnails : bool, default False
If true, the `thumbnail` asset of each item will be displayed on the map.
prop : str, optional
The STAC property to use for color-coding items.
cmap : str, optional
The name of a colormap to apply for color-coding. By default, only `colorbrewer` colors are
supported. Additional colors are available if `matplotlib` is installed.
vmin : float, optional
The minimum value for the color ramp. If none is given, it will be calculated from the
`prop`.
vmax : float, optional
The maximum value for the color ramp. If none is given, it will be calculated from the
`prop`.
categorical : bool, default False
If true, numeric properties are treated as categorical instead of continuous. Non-numeric
properties are always treated as categorical.
tooltip : bool, default True
If True, item metadata will be displayed on hover.
popup : bool, default False
If True, item metadata will be displayed on click.
fields : list
A list of metadata fields to display in the tooltip or popup. If not provided, all fields
are displayed.
extensions : list
A list of STAC extension field prefixes (like `eo` or `proj`) to include in the tooltip or
popup. If not provided, all extensions will be included. Base STAC properties will be
included regardless.
shared_fields: bool, default False
If true, only fields shared by all items will be displayed in the tooltip or popup.
Otherwise, missing fields will be populated with `None`.
add_id : bool, default True
If true, the STAC `id` of each item will be added to its properties in the tooltip and
popup.
m : folium.Map
Existing :external:class:`~folium.folium.Map` instance on which to draw the plot. If none is
provided, a new map will be created.
width : int, optional
Width of the map in pixels.
height : int, optional
Height of the map in pixels.
tiles : str
Map tileset to use, supported by :external:class:`~folium.folium.Map`.
attr : str, optional
Attribution information for custom tile sets.
zoom_to : bool, default False
If true, the map will zoom to the bounds of the items.
legend : bool, default True
Whether to show a legend for the color ramp.
layer_control : bool, default True
Whether to show the layer control.
control_scale : bool, default True
Whether to show the scale control.
highlight : bool, default True
Whether to highlight items on hover.
style_kwds: dict, default {}
Additional styles to be passed to the `style_function` of
:external:class:`~folium.features.GeoJson`. If `prop` is provided, `color` and `fillColor`
will be set automatically and override options passed to `style_kwds`.
highlight_kwds: dict, default {}
Additional styles to be passed to the `highlight_function` of
:external:class:`~folium.features.GeoJson`.
bounds_kwds: dict, default {}
Additional styles to be passed to :external:class:`~folium.features.GeoJson` for the
`bounds` or `intersects` layers.
tooltip_kwds: dict, default {}
Additional styles to be passed to :external:class:`~folium.features.GeoJsonTooltip`.
popup_kwds: dict, default {}
Additional styles to be passed to :external:class:`~folium.features.GeoJsonPopup`.
map_kwds: dict, default {}
Additional styles to be passed to :external:class:`~folium.folium.Map`, if an existing map
`m` is not given.
Returns
-------
folium.Map
The :external:class:`~folium.folium.Map` instance.
Examples
--------
>>> import stacmap, pystac
>>> catalog = pystac.Catalog.from_file(
"https://planet.com/data/stac/disasters/hurricane-harvey/hurricane-harvey-0831/catalog.json"
)
>>> stacmap.explore(catalog, prop="gsd", categorical=True)
"""
items = copy.deepcopy(get_items(stac))
if len(items) == 0:
raise ValueError("No STAC items were found.")
fc = STACFeatureCollection(items)
name = name if name is not None else items[0]["collection"]
highlight_kwds["fillOpacity"] = highlight_kwds.get("fillOpacity", 0.75)
if m is None:
m = _basemap(
tiles=tiles,
attr=attr,
width=width,
height=height,
control_scale=control_scale,
map_kwds=map_kwds,
)
else:
# Adding layers to a map that already contains a layer control causes rendering issues.
# To prevent that, we manually remove the layer control and add it back later.
layer_controls = [k for k in m._children.keys() if k.startswith("layer_control")]
[m._children.pop(lc) for lc in layer_controls]
def style_function(x: ItemDict) -> Dict[str, Any]:
item_style = style_kwds.copy()
if prop is not None:
item_style["color"] = x["properties"]["__stacmap_color"]
item_style["fillColor"] = x["properties"]["__stacmap_color"]
return item_style
def highlight_function(_: ItemDict) -> Dict[str, Any]:
if highlight is False:
return {}
return highlight_kwds
if prop is not None:
values = fc.get_values(prop)
categorical = categorical is True or not np.issubdtype(values.dtype, np.number)
if categorical:
categories = np.unique(values)
cmap = cmap if cmap else "Set1"
n_colors = len(categories)
colors = get_cmap(cmap, n_colors)
_set_categorical_colors(collection=fc, prop=prop, colors=colors)
if legend is True:
_add_categorical_legend(
categories=categories, colors=colors, caption=f"{name}: {prop}", m=m
)
else:
vmin = vmin if vmin is not None else values.min()
vmax = vmax if vmax is not None else values.max()
cmap = cmap if cmap else "RdBu_r"
n_colors = 255
colors = get_cmap(cmap, n_colors)
_set_continuous_colors(
collection=fc,
prop=prop,
colors=colors,
vmin=vmin,
vmax=vmax,
)
if legend is True:
_add_continuous_legend(
vmin=vmin, vmax=vmax, colors=colors, caption=f"{name}: {prop}", m=m
)
if bbox is not None or intersects is not None:
if bbox is not None and intersects is not None:
raise ValueError("Cannot specify both `bbox` and `intersects`.")
_add_bounds_to_map(
m=m, name=name, bbox=bbox, intersects=intersects, bounds_kwds=bounds_kwds
)
_add_footprints_to_map(
collection=fc,
m=m,
name=name,
fields=fields,
extensions=extensions,
shared_fields=shared_fields,
tooltip=tooltip,
popup=popup,
zoom_to=zoom_to,
style_function=style_function,
highlight_function=highlight_function,
popup_kwds=popup_kwds,
tooltip_kwds=tooltip_kwds,
add_id=add_id,
)
if thumbnails is True:
_add_thumbnails_to_map(fc, m, name=name)
# LayerControl must be added after all layers are added to the map
# https://github.com/python-visualization/folium/issues/1553
if layer_control is True:
folium.LayerControl(hideSingleBase=True, position="topleft").add_to(m)
return m
def _basemap(
tiles: str,
attr: Optional[str],
control_scale: bool,
width: Optional[Union[str, int]],
height: Optional[Union[str, int]],
map_kwds: Optional[Dict[str, Any]],
) -> folium.Map:
# folium.Map fails with None as width or height, so only pass if they are not None
size_kwds = {}
if width is not None:
size_kwds["width"] = width
if height is not None:
size_kwds["height"] = height
m = folium.Map(tiles=tiles, attr=attr, control_scale=control_scale, **size_kwds, **map_kwds)
folium.plugins.Fullscreen().add_to(m)
return m
def _add_footprints_to_map(
collection: STACFeatureCollection,
m: folium.Map,
name: str,
fields: Optional[List[str]],
extensions: Optional[List[str]],
shared_fields: bool,
tooltip: bool,
popup: bool,
zoom_to: bool,
style_function: Callable[[ItemDict], Dict[str, Any]],
highlight_function: Callable[[ItemDict], Dict[str, Any]],
add_id: bool,
popup_kwds: Dict[str, Any],
tooltip_kwds: Dict[str, Any],
) -> None:
if fields is None:
fields = (
collection.get_all_props() if shared_fields is False else collection.get_shared_props()
)
if add_id is True:
fields = ["id"] + fields
for feature in collection.features:
id = feature.properties.get("id", feature.id)
feature.properties = {"id": id, **feature.properties}
# Filter by field extensions
if extensions is not None:
fields = [f for f in fields if len(f.split(":")) == 1 or f.split(":")[0] in extensions]
# Populate all missing fields with None to avoid tooltip errors
if shared_fields is False:
for feature in collection.features:
for field in fields:
feature.properties[field] = feature.properties.get(field)
tooltip = folium.GeoJsonTooltip(fields, **tooltip_kwds) if tooltip else None
popup = folium.GeoJsonPopup(fields, **popup_kwds) if popup else None
geojson = folium.GeoJson(
data=collection.to_dict(),
tooltip=tooltip,
popup=popup,
name=f"{name} - Footprints",
style_function=style_function,
highlight_function=highlight_function,
)
if zoom_to:
m.fit_bounds(geojson.get_bounds())
geojson.add_to(m)
def _add_thumbnails_to_map(collection: STACFeatureCollection, m: folium.Map, name: str) -> None:
features = collection.features
thumbnails = folium.FeatureGroup(name=f"{name} - Thumbnails")
for feat in features:
try:
url = feat.assets["thumbnail"]["href"]
except KeyError:
continue
thumb_bounds = folium.GeoJson(feat.to_dict()).get_bounds()
overlay = folium.raster_layers.ImageOverlay(url, bounds=thumb_bounds)
overlay.add_to(thumbnails)
if len(thumbnails._children) == 0:
raise ValueError("Items do not have thumbnail links.")
thumbnails.add_to(m)
def _add_bounds_to_map(
m: folium.Map,
name: str,
bbox: Optional[List[float]],
intersects: Optional[GeoJSON],
bounds_kwds: Dict[str, Any],
) -> None:
if bbox is not None:
w, s, e, n = bbox
coords = [
(w, s),
(w, n),
(e, n),
(e, s),
(w, s),
]
geometry = dict(type="Polygon", coordinates=[coords])
elif intersects is not None:
geometry = intersects
bounds_kwds["fill"] = bounds_kwds.get("fill", False)
bounds_kwds["interactive"] = bounds_kwds.get("interactive", False)
geojson = folium.GeoJson(
data=geometry, name=f"{name} - Bounds", style_function=lambda _: bounds_kwds
)
geojson.add_to(m)
def _set_continuous_colors(
*,
collection: STACFeatureCollection,
prop: str,
colors: NDArray[np.unicode_],
vmin: float,
vmax: float,
) -> None:
"""Set the `__stacmap_color` property of each item in the collection based on the
continuous value of the given property."""
features = collection.features
n_colors = len(colors)
for feat in features:
feat_value = feat.properties[prop]
color_idx = (
int(((feat_value - vmin) / (vmax - vmin)) * (n_colors - 1)) if vmax != vmin else 0
)
# Clamp the index to avoid index errors that may occur with custom vmin and vmax
color_idx = max(0, min(color_idx, n_colors - 1))
color = colors[color_idx]
feat.properties["__stacmap_color"] = color
def _set_categorical_colors(
*, collection: STACFeatureCollection, prop: str, colors: NDArray[np.unicode_]
) -> None:
"""Set the `__stacmap_color` property of each item in the collection based on the
categorical value of the given property. Add the categorical legend to the map"""
features = collection.features
categories = np.unique(collection.get_values(prop))
for feat in features:
feat_value = feat.properties[prop]
color = colors[np.where(categories == feat_value)[0][0]]
feat.properties["__stacmap_color"] = color
def _add_continuous_legend(
vmin: float, vmax: float, colors: NDArray[np.unicode_], caption: str, m: folium.Map
) -> branca.colormap.LinearColormap:
"""Add a continuous legend color ramp to the map."""
color_ramp = branca.colormap.LinearColormap(
colors=colors, vmin=vmin, vmax=vmax, caption=caption
)
color_ramp.add_to(m)
return color_ramp
def _add_categorical_legend(
categories: NDArray[np.unicode_],
colors: NDArray[np.unicode_],
caption: str,
m: folium.Map,
) -> branca.element.MacroElement:
"""Add a categorical legend to the map.
Adapted from code written by Michel Metran (@michelmetran) and released on GitHub
(https://github.com/michelmetran/package_folium) under MIT license.
Copyright (c) 2020 Michel Metran
Parameters
----------
m : folium.Map
Existing map instance on which to draw the plot
title : str
title of the legend (e.g. column name)
categories : list-like
list of categories
colors : list-like
list of colors (in the same order as categories)
"""
head = """
{% macro header(this, kwargs) %}
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<script>$( function() {
$( ".maplegend" ).draggable({
start: function (event, ui) {
$(this).css({
right: "auto",
top: "auto",
bottom: "auto"
});
}
});
});
</script>
<style type='text/css'>
.maplegend {
position: absolute;
z-index:9999;
background-color: rgba(255, 255, 255, .8);
border-radius: 5px;
box-shadow: 0 0 15px rgba(0,0,0,0.2);
padding: 10px;
font: 12px/14px Arial, Helvetica, sans-serif;
right: 10px;
top: 20px;
}
.maplegend .legend-title {
text-align: left;
margin-bottom: 5px;
font-weight: bold;
}
.maplegend .legend-scale ul {
margin: 0;
margin-bottom: 0px;
padding: 0;
float: left;
list-style: none;
}
.maplegend .legend-scale ul li {
list-style: none;
margin-left: 0;
line-height: 16px;
margin-bottom: 2px;
}
.maplegend ul.legend-labels li span {
display: block;
float: left;
height: 14px;
width: 14px;
margin-right: 5px;
margin-left: 0;
border: 0px solid #ccc;
}
.maplegend .legend-source {
color: #777;
clear: both;
}
.maplegend a {
color: #777;
}
</style>
{% endmacro %}
"""
macro = branca.element.MacroElement()
macro._template = branca.element.Template(head)
m.get_root().add_child(macro)
legend_items = "\n".join(
[
f"<li><span style='background:{color}'></span>{label}</li>"
for color, label in zip(colors, categories)
]
)
legend_body = f"""
<div id='maplegend {caption}' class='maplegend'>
<div class='legend-title'>{caption}</div>
<div class='legend-scale'>
<ul class='legend-labels'>
{legend_items}
</ul>
</div>
</div>
"""
legend = branca.element.Element(legend_body, "legend")
m.get_root().html.add_child(legend)
return legend