Skip to content
Feeding a clanker? Grab this page as raw .md

HeatmapSelect API#

HeatmapSelect renders a 2D array as a dense grid — one image pixel per cell, in the spirit of the parameter spaces in Bret Victor's Up and Down the Ladder of Abstraction. Hover or click a cell in the body to pick one point of the sweep, or grab the left or bottom gutter to pin a whole row or column. The three pins are independent and coexist, so you can hold a cell, a row and a column at once. The values behind the picture never cross the wire: the widget reports indices and you do the slicing, which is what lets a 14 641-cell field stay a single PNG. Reach for it when a sweep has two knobs and you want to see the runs behind any slice of it.

See also: ChartSelect for box and lasso selection over a matplotlib figure, ChartPuck for dragging a single point across a chart, and Slider2D for picking a continuous (x, y) pair rather than a grid cell.

Bases: AnyWidget

A dense parameter-space grid where you pick one cell or a whole row/column.

Modelled on the grid in Bret Victor's Up and Down the Ladder of Abstraction. The body picks a single cell; the left gutter picks a whole row (a horizontal band); the bottom gutter picks a whole column (a vertical band).

Hovering previews, clicking pins. The three pins are independent and coexist — you can hold a cell, a row and a column at once, and clicking one region only replaces that region's pin. Double-clicking a region drops just that pin. What to make of a combination is the caller's business.

One pixel of image is one grid cell, and the values behind the picture never cross the wire — the widget hands back indices, and you do the slicing yourself with values[widget.pinned_row]. Use x_at/y_at to turn an index back into a data coordinate.

Examples:

import numpy as np
import marimo as mo
from wigglystuff import HeatmapSelect

steps = np.random.rand(100, 91)
widget = mo.ui.anywidget(
    HeatmapSelect(
        steps,
        x_range=(0, 90),
        y_range=(0.1, 10.0),
        x_label="bend angle",
        y_label="turning rate",
        x_suffix="°",
        y_suffix="°",
    )
)
widget

Then react to the pins in another cell. They are independent, so this is three ifs rather than a branch:

if widget.pinned_row is not None:
    row_sweep = steps[widget.pinned_row, :]
if widget.pinned_col is not None:
    col_sweep = steps[:, widget.pinned_col]
if widget.pinned_cell is not None:
    row, col = widget.pinned_cell
    value = steps[row, col]
    x, y = widget.x_at(col), widget.y_at(row)

Coloring follows matplotlib, so a Bret-Victor-style crash region is just a masked array plus a "bad" color — the widget has no concept of one:

import matplotlib

HeatmapSelect(
    np.ma.masked_where(crashed, distance),
    cmap=matplotlib.colormaps["gray"].with_extremes(bad="red"),
)

Create a HeatmapSelect widget.

Parameters:

Name Type Description Default
image Any

The grid bitmap — one pixel per cell. Accepts a base64/data-URI PNG string, a path, a PIL Image, an (rows, cols, 3|4) uint8 array, or a (rows, cols) numeric array colormapped via cmap/norm/vmin/vmax.

required
x_range Tuple[float, float]

Data coordinates of the first and last column centers.

(0.0, 1.0)
y_range Tuple[float, float]

Data coordinates of the first and last row centers.

(0.0, 1.0)
x_label str

Label drawn under the bottom gutter.

''
y_label str

Label drawn beside the left gutter.

''
x_suffix str

Suffix appended to x tick labels, e.g. "°".

''
y_suffix str

Suffix appended to y tick labels.

''
origin str

"lower" puts image row 0 at y_range[0] (bottom), "upper" puts it at the top, matching matplotlib's imshow.

'lower'
cell_width int

Screen pixels per cell horizontally.

4
cell_height int

Screen pixels per cell vertically.

4
row_color str

Tint for the row band grabbed from the left (y) axis, e.g. "#1f4fd8". Empty uses the --hs-row-color CSS variable.

''
col_color str

Tint for the column band grabbed from the bottom (x) axis. Empty uses the --hs-col-color CSS variable.

''
cmap Any

Colormap name or matplotlib.colors.Colormap, for 2D numeric arrays. Grayscale by default. Mask cells (or make them NaN) and use cmap.with_extremes(bad="red") to color a crash region.

'gray'
norm Any

Optional matplotlib.colors.Normalize instance, e.g. LogNorm(). Mutually exclusive with vmin/vmax.

None
vmin Optional[float]

Optional lower end of the color scale.

None
vmax Optional[float]

Optional upper end of the color scale.

None
throttle Union[int, str]

How often hover updates reach Python — 0 for every mouse move, an int for milliseconds, or "dragend" to send hover only on release. Pin changes always sync immediately, whatever this is set to.

50
**kwargs Any

Forwarded to anywidget.AnyWidget.

{}
Source code in wigglystuff/heatmap_select.py
def __init__(
    self,
    image: Any,
    *,
    x_range: Tuple[float, float] = (0.0, 1.0),
    y_range: Tuple[float, float] = (0.0, 1.0),
    x_label: str = "",
    y_label: str = "",
    x_suffix: str = "",
    y_suffix: str = "",
    origin: str = "lower",
    cell_width: int = 4,
    cell_height: int = 4,
    row_color: str = "",
    col_color: str = "",
    cmap: Any = "gray",
    norm: Any = None,
    vmin: Optional[float] = None,
    vmax: Optional[float] = None,
    throttle: Union[int, str] = 50,
    **kwargs: Any,
):
    """Create a HeatmapSelect widget.

    Args:
        image: The grid bitmap — one pixel per cell. Accepts a base64/data-URI
            PNG string, a path, a PIL ``Image``, an ``(rows, cols, 3|4)`` uint8
            array, or a ``(rows, cols)`` numeric array colormapped via
            ``cmap``/``norm``/``vmin``/``vmax``.
        x_range: Data coordinates of the first and last *column* centers.
        y_range: Data coordinates of the first and last *row* centers.
        x_label: Label drawn under the bottom gutter.
        y_label: Label drawn beside the left gutter.
        x_suffix: Suffix appended to x tick labels, e.g. ``"°"``.
        y_suffix: Suffix appended to y tick labels.
        origin: ``"lower"`` puts image row 0 at ``y_range[0]`` (bottom),
            ``"upper"`` puts it at the top, matching matplotlib's ``imshow``.
        cell_width: Screen pixels per cell horizontally.
        cell_height: Screen pixels per cell vertically.
        row_color: Tint for the row band grabbed from the left (y) axis, e.g.
            ``"#1f4fd8"``. Empty uses the ``--hs-row-color`` CSS variable.
        col_color: Tint for the column band grabbed from the bottom (x) axis.
            Empty uses the ``--hs-col-color`` CSS variable.
        cmap: Colormap name or ``matplotlib.colors.Colormap``, for 2D numeric
            arrays. Grayscale by default. Mask cells (or make them NaN) and
            use ``cmap.with_extremes(bad="red")`` to color a crash region.
        norm: Optional ``matplotlib.colors.Normalize`` instance, e.g.
            ``LogNorm()``. Mutually exclusive with ``vmin``/``vmax``.
        vmin: Optional lower end of the color scale.
        vmax: Optional upper end of the color scale.
        throttle: How often hover updates reach Python — ``0`` for every
            mouse move, an int for milliseconds, or ``"dragend"`` to send
            hover only on release. Pin changes always sync immediately,
            whatever this is set to.
        **kwargs: Forwarded to ``anywidget.AnyWidget``.
    """
    if origin not in ("lower", "upper"):
        raise ValueError(f"origin must be 'lower' or 'upper', got {origin!r}")
    if cell_width < 1 or cell_height < 1:
        raise ValueError("cell_width and cell_height must be at least 1")
    for name, value in (("x_range", x_range), ("y_range", y_range)):
        if len(tuple(value)) != 2:
            raise ValueError(f"{name} must be a (min, max) pair, got {value!r}")

    # Remembered so set_image() recolors the same way without repeating them.
    self._color_kwargs = {"cmap": cmap, "norm": norm, "vmin": vmin, "vmax": vmax}
    image_base64, n_rows, n_cols = _image_to_png_base64(
        image, **self._color_kwargs
    )

    super().__init__(
        image_base64=image_base64,
        n_rows=n_rows,
        n_cols=n_cols,
        x_range=tuple(float(v) for v in x_range),
        y_range=tuple(float(v) for v in y_range),
        x_label=x_label,
        y_label=y_label,
        x_suffix=x_suffix,
        y_suffix=y_suffix,
        origin=origin,
        cell_width=cell_width,
        cell_height=cell_height,
        row_color=row_color,
        col_color=col_color,
        throttle=throttle,
        **kwargs,
    )

height property #

height: int

Height of the grid area in screen pixels (excludes the gutters).

selection property #

selection: dict

All six selection traits in one dict, handy for one-shot reads.

width property #

width: int

Width of the grid area in screen pixels (excludes the gutters).

clear #

clear() -> None

Drop all three pins and the current hover.

Source code in wigglystuff/heatmap_select.py
def clear(self) -> None:
    """Drop all three pins and the current hover."""
    with self.hold_sync():
        self.pinned_cell = None
        self.pinned_row = None
        self.pinned_col = None
        self.hover_cell = None
        self.hover_row = None
        self.hover_col = None

set_image #

set_image(image: Any, **color_kwargs: Any) -> None

Swap the grid bitmap, re-deriving the grid shape from its pixels.

Parameters:

Name Type Description Default
image Any

A new image, in any of the forms the constructor accepts.

required
**color_kwargs Any

Optional cmap/norm/vmin/vmax overrides; anything omitted reuses what the constructor was given.

{}
Source code in wigglystuff/heatmap_select.py
def set_image(self, image: Any, **color_kwargs: Any) -> None:
    """Swap the grid bitmap, re-deriving the grid shape from its pixels.

    Args:
        image: A new image, in any of the forms the constructor accepts.
        **color_kwargs: Optional ``cmap``/``norm``/``vmin``/``vmax`` overrides;
            anything omitted reuses what the constructor was given.
    """
    self._color_kwargs.update(color_kwargs)
    image_base64, n_rows, n_cols = _image_to_png_base64(
        image, **self._color_kwargs
    )
    with self.hold_sync():
        self.image_base64 = image_base64
        self.n_rows = n_rows
        self.n_cols = n_cols

x_at #

x_at(col: Optional[int]) -> Optional[float]

Data coordinate at the center of a column, or None for None.

Parameters:

Name Type Description Default
col Optional[int]

Column index, or None.

required

Returns:

Type Description
Optional[float]

float | None: The x coordinate, interpolated across x_range.

Source code in wigglystuff/heatmap_select.py
def x_at(self, col: Optional[int]) -> Optional[float]:
    """Data coordinate at the center of a column, or ``None`` for ``None``.

    Args:
        col: Column index, or ``None``.

    Returns:
        float | None: The x coordinate, interpolated across ``x_range``.
    """
    if col is None:
        return None
    lo, hi = self.x_range
    if self.n_cols <= 1:
        return lo
    return lo + (col / (self.n_cols - 1)) * (hi - lo)

y_at #

y_at(row: Optional[int]) -> Optional[float]

Data coordinate at the center of a row, or None for None.

Parameters:

Name Type Description Default
row Optional[int]

Row index, or None.

required

Returns:

Type Description
Optional[float]

float | None: The y coordinate, interpolated across y_range.

Source code in wigglystuff/heatmap_select.py
def y_at(self, row: Optional[int]) -> Optional[float]:
    """Data coordinate at the center of a row, or ``None`` for ``None``.

    Args:
        row: Row index, or ``None``.

    Returns:
        float | None: The y coordinate, interpolated across ``y_range``.
    """
    if row is None:
        return None
    lo, hi = self.y_range
    if self.n_rows <= 1:
        return lo
    return lo + (row / (self.n_rows - 1)) * (hi - lo)

Synced traitlets#

Traitlet Type Notes
image_base64 str The grid bitmap as a PNG data URI. One image pixel is one cell.
n_rows int Grid rows, derived from the image height.
n_cols int Grid columns, derived from the image width.
x_range tuple[float, float] Data coordinates of the first and last column centers.
y_range tuple[float, float] Data coordinates of the first and last row centers.
x_label str Label under the bottom gutter. May contain \n to stack lines.
y_label str Label beside the left gutter. May contain \n to stack lines.
x_suffix str Appended to x tick labels, e.g. "°".
y_suffix str Appended to y tick labels.
origin str "lower" puts image row 0 at the bottom (like imshow), "upper" at the top.
cell_width int Screen pixels per cell horizontally.
cell_height int Screen pixels per cell vertically.
row_color str Tint for the row band from the left (y) axis. Empty uses --hs-row-color.
col_color str Tint for the column band from the bottom (x) axis. Empty uses --hs-col-color.
pinned_cell tuple[int, int] \| None (row, col) of the pinned cell.
pinned_row int \| None Row index pinned from the left axis.
pinned_col int \| None Column index pinned from the bottom axis.
hover_cell tuple[int, int] \| None (row, col) under the cursor, when it is over the grid body.
hover_row int \| None Row under the cursor, when it is over the left gutter.
hover_col int \| None Column under the cursor, when it is over the bottom gutter.
throttle int \| str Hover sync rate. 0 = every move, int = ms, "dragend" = on release. Pin changes always sync immediately.

Interaction#

Gesture Result
Hover the body Sets hover_cell.
Hover the left gutter Sets hover_row — a horizontal band.
Hover the bottom gutter Sets hover_col — a vertical band.
Click (anywhere, including a gutter) Pins that region. Only that region's pin is replaced.
Drag Keeps moving that region's pin.
Double-click a region Drops only that region's pin.
Mouse out Clears the hover traits; pins are untouched.

The three pins are independent, so a cell, a row and a column can all be held at once. Hovering never disturbs a pin — it draws a faint ghost instead.

Coloring#

Pass a 2D numeric array and the widget colormaps it with matplotlib's own conventions: cmap (a name or a Colormap), norm, vmin, vmax. The default is grayscale. Cells that are masked or non-finite take the colormap's "bad" color, which is all you need for a crash region:

import matplotlib
import numpy as np
from wigglystuff import HeatmapSelect

HeatmapSelect(
    np.ma.masked_where(crashed, distance),
    cmap=matplotlib.colormaps["gray"].with_extremes(bad="red"),
)

Autoscaling is relative, exactly as with imshow: uniformly rescaling the data produces an identical picture. Pin vmin/vmax if you need an absolute scale across successive set_image calls.

You can also skip colormapping entirely and hand over a finished picture — a PIL image, an (rows, cols, 3|4) uint8 array, a path, or a base64 PNG.

Sizing#

The plot size is derived: n_cols * cell_width by n_rows * cell_height. Cells are therefore always whole pixel blocks and never shimmer, which is why there is no width/height argument — both are read-only properties.