Glaciers#

Glaciers of the World#

Explore the location, climate, and size of more than 200,000 glaciers. Pick a region or box- and lasso-select on any plot, and a single hv.link_selections instance filters the map, the climate envelope, the histograms, and the summary figures together. Run with panel serve glaciers.ipynb.

Data: glacier attributes from the Open Global Glacier Model (OGGM), derived from the Randolph Glacier Inventory 6.0.

import holoviews as hv
import pandas as pd
import panel as pn
import param
import xyzservices.providers as xyz
from colorcet import bmy
from holoviews.operation import histogram
from holoviews.operation.datashader import rasterize

hv.extension("bokeh")
pn.extension(sizing_mode="stretch_width", throttled=True)

DATA_URL = "https://datasets.holoviz.org/oggm_glaciers/v1/oggm_glaciers.parq"
ICE = "#1b6f93"
MELT = "#d9745b"
ALL_REGIONS = "All regions"
TYPOGRAPHY = {
    "kpi": {"fontSize": "1.7rem", "fontWeight": 600, "whiteSpace": "nowrap", "fontVariantNumeric": "tabular-nums"},
}
# First-order regions of the Randolph Glacier Inventory 6.0, keyed by the number in each RGI ID.
REGIONS = {
    1: "Alaska", 2: "Western Canada and US", 3: "Arctic Canada North", 4: "Arctic Canada South",
    5: "Greenland Periphery", 6: "Iceland", 7: "Svalbard and Jan Mayen", 8: "Scandinavia",
    9: "Russian Arctic", 10: "North Asia", 11: "Central Europe", 12: "Caucasus and Middle East",
    13: "Central Asia", 14: "South Asia West", 15: "South Asia East", 16: "Low Latitudes",
    17: "Southern Andes", 18: "New Zealand",
}

# Dragging selects rather than pans, since cross-filtering is the point of every view.
PLOT_TOOLS = {"default_tools": ["pan", "wheel_zoom", "reset"], "active_tools": ["box_select", "wheel_zoom"]}
hv.opts.defaults(
    hv.opts.Histogram(responsive=True, height=280, line_color=None, **PLOT_TOOLS),
    hv.opts.Image(responsive=True, **PLOT_TOOLS),
)


@pn.cache
def load_glaciers():
    df = pd.read_parquet(DATA_URL)
    df["region"] = df.rgi_id.str[6:8].astype(int)
    return df

Summaries#

The summary figures are plain functions of the selected DataFrame, so they can be bound directly to the selection.

REGION_NUMBERS = {name: number for number, name in REGIONS.items()}


def count(selected):
    return f"{len(selected):,}"


def area(selected):
    return f"{selected.area_km2.sum():,.0f} km²"


def share(selected, total_area):
    return f"{100 * selected.area_km2.sum() / total_area:.1f}%"


def elevation(selected):
    median = selected.mean_elev.median()
    return "–" if pd.isna(median) else f"{median:,.0f} m"


def region_expr(region):
    if region == ALL_REGIONS:
        return None
    return hv.dim("region") == REGION_NUMBERS[region]


def card(title, caption, plot):
    return pn.ui.Paper(
        pn.ui.Typography(title, variant="h6", margin=0),
        pn.ui.Typography(caption, variant="body2", margin=(0, 0, 8, 0), sx={"color": "text.secondary"}),
        pn.ui.HoloViews(plot, linked_axes=False),
        variant="outlined", sx={"p": 2},
    )

The app#

Every plot is built from one hv.Dataset that declares all columns, so a brush on the map, a histogram bin range, or a region expression selects the same glaciers everywhere. The summary cards bind to link.selection_param, which yields the filtered DataFrame each time the selection changes.

class GlacierExplorer(pn.viewable.Viewer):
    region = param.Selector(
        default=ALL_REGIONS, objects=[ALL_REGIONS, *REGIONS.values()],
        doc="RGI first-order region to select across all views.",
    )
    clear = param.Event(doc="Clear the region and any brushed selection.")

    def __init__(self, **params):
        super().__init__(**params)
        self.glaciers = load_glaciers()
        self._syncing = False
        self.link = hv.link_selections.instance()
        self._selected = self.link.selection_param(self.glaciers)
        self._region = pn.ui.Select.from_param(self.param.region, label="RGI region", width=260)
        self._clear = pn.ui.Button.from_param(
            self.param.clear, label="Clear selection", icon="restart_alt", variant="outlined",
            width=170, align="end",
        )
        pn.bind(self._sync_region, self.link.param.selection_expr, watch=True)

    def _plots(self):
        # Declaring every column as a value dimension lets a selection on any
        # view (or a region expression) filter all of the others.
        dataset = hv.Dataset(self.glaciers, [], list(self.glaciers.columns))
        locations = rasterize(dataset.to(hv.Points, ["x", "y"], [])).opts(
            cmap=bmy, cnorm="log", colorbar=True, clabel="Glaciers per pixel",
            xaxis=None, yaxis=None, xlim=(-2.0e7, 2.0e7), ylim=(-7.45e6, 1.84e7),
            height=460,
        )
        climate = rasterize(dataset.to(hv.Points, ["avg_prcp", "mean_elev"], [])).opts(
            cmap="kbc_r", cnorm="log", height=280, xlabel="Precipitation (mm/yr)",
            ylabel="Mean elevation (m)",
        )
        temperature = histogram(dataset, dimension="avg_temp_at_mean_elev", num_bins=40).opts(
            color=MELT, xlabel="Temperature at mean elevation (°C)", ylabel="Glaciers",
        )
        precipitation = histogram(dataset, dimension="avg_prcp", num_bins=40).opts(
            color=ICE, xlabel="Precipitation (mm/yr)", ylabel="Glaciers",
        )
        return locations, climate, temperature, precipitation

    def _kpi_cards(self):
        kpis = [
            ("Glaciers selected", pn.bind(count, self._selected)),
            ("Ice area", pn.bind(area, self._selected)),
            ("Share of global ice area", pn.bind(share, self._selected, self.glaciers.area_km2.sum())),
            ("Median elevation", pn.bind(elevation, self._selected)),
        ]
        return [
            pn.ui.Grid(
                pn.ui.Paper(
                    pn.ui.Typography(label, variant="overline", margin=0, sx={"color": "text.secondary"}),
                    pn.ui.Typography(value, variant="kpi", margin=0),
                    variant="outlined", sx={"p": 2},
                ),
                size={"xs": 12, "sm": 6, "md": 3},
            )
            for label, value in kpis
        ]

    def _chart_cards(self):
        locations, climate, temperature, precipitation = self._plots()
        world = card(
            "Where the ice is",
            "Glacier outlines from RGI 6.0, aggregated to screen pixels on a log color scale.",
            # Esri's gray canvas needs no API key, unlike CARTO's; fading it lets the plot background show through as white.
            hv.Tiles(xyz.Esri.WorldGrayCanvas).opts(alpha=0.45) * self.link(locations),
        )
        charts = [
            ("Climate envelope", "Where glaciers sit between precipitation and elevation.", climate),
            ("Temperature", "Mean temperature at each glacier's mean elevation.", temperature),
            ("Precipitation", "Mean annual precipitation at each glacier.", precipitation),
        ]
        return [
            pn.ui.Grid(world, size={"xs": 12}),
            *(
                pn.ui.Grid(card(title, caption, self.link(plot)), size={"xs": 12, "md": 4})
                for title, caption, plot in charts
            ),
        ]

    @param.depends("region", watch=True)
    def _update_region(self):
        if self._syncing:
            return
        self._syncing = True
        try:
            with pn.io.hold():
                # link_selections drops a new expression assigned over an active
                # one once plots are linked, so clear it first.
                self.link.selection_expr = None
                self.link.selection_expr = region_expr(self.region)
        finally:
            self._syncing = False

    def _sync_region(self, expr):
        # A brush replaces the region expression, so the region no longer describes the selection.
        if self._syncing or repr(expr) == repr(region_expr(self.region)):
            return
        self._syncing = True
        try:
            self.region = ALL_REGIONS
        finally:
            self._syncing = False

    @param.depends("clear", watch=True)
    def _clear_selection(self):
        self.region = ALL_REGIONS
        self.link.selection_expr = None

    def __panel__(self):
        return pn.ui.Page(
            title="Glaciers of the World",
            # Keep the header on the deep brand blue so the white title stays legible in dark mode.
            sx={"& .header": {"backgroundColor": ICE, "backgroundImage": "none"}},
            theme_config={
                "light": {"palette": {"primary": {"main": ICE}, "secondary": {"main": MELT}}, "typography": TYPOGRAPHY},
                # The light-mode blue is too dark to read as text on a dark surface.
                "dark": {"palette": {"primary": {"main": "#6cb8dc"}, "secondary": {"main": MELT}}, "typography": TYPOGRAPHY},
            },
            main=[pn.ui.Container(
                pn.ui.Column(
                    pn.ui.FlexBox(
                        pn.ui.Typography(
                            "More than 200,000 glaciers, from the Alaskan coast to the Southern Andes. "
                            "Box- or lasso-select on any plot, or pick a region, to follow the same "
                            "glaciers through every view.",
                            variant="body1", sizing_mode="stretch_width", styles={"flex": "1 1 320px"},
                        ),
                        pn.ui.PNG(
                            "https://raw.githubusercontent.com/OGGM/oggm/master/docs/_static/logos/oggm_s_alpha.png",
                            link_url="https://oggm.org/", height=64, width=150, sizing_mode="fixed",
                        ),
                        align_items="center", flex_wrap="wrap",
                    ),
                    pn.ui.FlexBox(self._region, self._clear, align_items="center", flex_wrap="wrap"),
                    pn.ui.Grid(
                        *self._kpi_cards(),
                        *self._chart_cards(),
                        container=True, spacing=2,
                    ),
                    pn.ui.Markdown(
                        "Data: glacier attributes computed by the [Open Global Glacier Model](https://oggm.org/) "
                        "from the [Randolph Glacier Inventory 6.0](https://www.glims.org/RGI/). Climate "
                        "values are 1981–2010 averages. Antarctic glaciers are not included. "
                        f"[Download the extract]({DATA_URL}).",
                        styles={"color": "var(--mui-palette-text-secondary)"},
                    ),
                ),
                width_option="xl", margin=(10, 0),
            )],
        )
explorer = GlacierExplorer()

explorer
if pn.state.served:
    explorer.servable()