Skip to content

feat: mo.ui.matplotlib()#8342

Merged
akshayka merged 35 commits intomainfrom
aka/ui-matplotlib
Feb 19, 2026
Merged

feat: mo.ui.matplotlib()#8342
akshayka merged 35 commits intomainfrom
aka/ui-matplotlib

Conversation

@akshayka
Copy link
Contributor

@akshayka akshayka commented Feb 17, 2026

This PR adds a new public API, mo.ui.matplotlib(axis: plt.Axes, *, debounce: bool=False.), which adds reactive selection to a matplotlib Axes. This API is designed for scatter plots and scatter plot selections, but should be extensible enough to support other plot types and interactions (such as span selections)/geometries in the future.

This feature was inspired by and based on @koaning's Wigglystuff ChartSelect anywidget for matplotlib.

Selections

Two types of selections are supported: box and lasso. Box is the default selection, with Shift+click triggering lasso selection.

Python usage

import matplotlib.pyplot as plt
import marimo as mo
import numpy as np

x = np.arange(5)
y = x**2
plt.scatter(x=x, y=y)
fig = mo.ui.matplotlib(plt.gca())
fig
# Filter data using the selection
mask = fig.value.get_mask(x, y)
selected_x, selected_y = x[mask], y[mask]

If debounce=True is passed to the constructor, data is only sent back on mouse up.

value type

The element's value is a frozen dataclass containing information about the interaction and selection. Each dataclass exposes a get_mask(x: np.typing.ArrayLike, y: np.typing.ArrayLike) method which returns a boolean mask for indexing into the scattered data.

An empty selection returns an EmptySelection sentinel object which is False-y, so users can write code like

if fig.value:
  # do something with selection
  ...

EmptySelection.get_mask(x, y) does return an all-False mask, so users can index into the original data without checking if the selection is empty.

The dataclass type is not returned as part of the public API. We could extend the class of interactions/geometries handled by adding new classes in the future.

Smoke test

This PR includes a smoke test that exercises linear and log scale axes

Media

mo-ui-matplotlib.mp4
Shift+drag draws a freehand polygon selection in addition to the
existing box selection. Both selection types can be dragged after
creation and cleared by clicking outside. The value format is now
a tagged union: {"type": "box"|"lasso", "data": ...} for
extensibility. All helper methods (get_bounds, get_vertices,
contains_point, get_mask, get_indices) handle both types.
@vercel
Copy link

vercel bot commented Feb 17, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
marimo-docs Ready Ready Preview, Comment Feb 19, 2026 6:49pm

Request Review

@akshayka akshayka removed the request for review from Light2Dark February 17, 2026 18:04
@github-actions github-actions bot added the documentation Improvements or additions to documentation label Feb 17, 2026
…ents

Replace useState-driven interaction state with useRef + requestAnimationFrame
to eliminate React re-renders on every mouse move during drawing/dragging.
Add DataPoint type to distinguish data coordinates from pixel coordinates,
Escape key to cancel in-progress selections, cursor feedback (crosshair/move),
touch-action:none for mobile support, and smarter onMouseLeave behavior that
doesn't finalize selections unexpectedly.
Implements a version of the suggestion in
[review](#8342 (comment)).
Since all interaction state already lives in refs and React does no
rendering work (it's all canvas), separating the DOM/canvas logic into
its own class gives a cleaner boundary between React lifecycle and
imperative drawing code.

These chnages extract into an imperative `MatplotlibRenderer` class
without React. The class uses `AbortSignal` for cleanup (all
`addEventListener` calls pass `{ signal }` for automatic removal) and a
generation counter for stale image load guards. The React component
becomes a thin lifecycle shell that creates the renderer on mount, syncs
props via `update()` each render, and aborts the controller on unmount.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Adds d3-brush-style resize behavior: hovering box edges/corners shows
directional cursors, and dragging them resizes the selection using an
anchor + axis-lock approach.

These changes required refactoring interaction state to a nested state
machine. Replaces flat InteractionMode/InteractionState with a nested
discriminated union (`Interaction = idle | box | lasso`) where each
selection type carries its own typed action (drawing, dragging,
resizing):

| Interaction | Actions |
   |-------------|---------|
   | `idle` | |
   | `box` | `null` · `drawing` · `dragging` · `resizing` |
   | `lasso` | `null` · `drawing` · `dragging` |


https://github.com/user-attachments/assets/d7a41e87-00d1-41c0-8240-839e73f2035b
On HiDPI/Retina displays the selection box and lasso overlays rendered
blurry because the canvas backing store matched the logical pixel
dimensions rather than the physical ones. The browser stretched the 1x
canvas to fill the CSS size, producing soft edges on every fillRect,
strokeRect, and lineTo call.

| before | after |
   |-------------|---------|
| <img
src="https://github.com/user-attachments/assets/ac089535-65b5-417b-9736-af6f6cdaf9b4"
/> | <img
src="https://github.com/user-attachments/assets/4d9499a6-47aa-4e61-b26f-d0159b92cc01"
/> |
Copy link
Collaborator

@dmadisetti dmadisetti left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left general comment on the api. Super fun addition, I don't think my comments are a blocker- just wanted to raise some thoughts on misuse / cumbersome nature of calling get_mask

manzt
manzt previously approved these changes Feb 19, 2026
@dmadisetti
Copy link
Collaborator

Failing test is relevant but lgtm otherwise

dmadisetti
dmadisetti previously approved these changes Feb 19, 2026
@akshayka akshayka merged commit 4fe9ebe into main Feb 19, 2026
28 of 43 checks passed
@akshayka akshayka deleted the aka/ui-matplotlib branch February 19, 2026 18:57
LiquidGunay pushed a commit to LiquidGunay/marimo that referenced this pull request Feb 21, 2026
This PR adds a new public API, `mo.ui.matplotlib(axis: plt.Axes, *,
debounce: bool=False.)`, which adds reactive selection to a matplotlib
Axes. This API is designed for scatter plots and scatter plot
selections, but should be extensible enough to support other plot types
and interactions (such as span selections)/geometries in the future.

This feature was inspired by and based on @koaning's `Wigglystuff`
`ChartSelect` anywidget for matplotlib.

## Selections

Two types of selections are supported: box and lasso. Box is the default
selection, with <kbd>Shift</kbd>+click triggering lasso selection.

## Python usage

```python
import matplotlib.pyplot as plt
import marimo as mo
import numpy as np

x = np.arange(5)
y = x**2
plt.scatter(x=x, y=y)
fig = mo.ui.matplotlib(plt.gca())
fig
```

```python
# Filter data using the selection
mask = fig.value.get_mask(x, y)
selected_x, selected_y = x[mask], y[mask]
```

If `debounce=True` is passed to the constructor, data is only sent back
on mouse up.

## `value` type

The element's `value` is a frozen dataclass containing information about
the interaction and selection. Each dataclass exposes a `get_mask(x:
np.typing.ArrayLike, y: np.typing.ArrayLike)` method which returns a
boolean mask for indexing into the scattered data.

An empty selection returns an `EmptySelection` sentinel object which is
`False-y`, so users can write code like

```python
if fig.value:
  # do something with selection
  ...
```

`EmptySelection.get_mask(x, y)` does return an all-`False` mask, so
users can index into the original data without checking if the selection
is empty.

The dataclass type is not returned as part of the public API. We could
extend the class of interactions/geometries handled by adding new
classes in the future.

## Smoke test

This PR includes a smoke test that exercises linear and log scale axes

## Media


https://github.com/user-attachments/assets/e085f4d0-d17b-4d44-854c-7c3f2f156b4d

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Trevor Manz <trevor.j.manz@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

3 participants