Skip to content

Commit b73a370

Browse files
axsseldzclaude
andauthored
feat(plotly): add reactive box plot selection support (#9010)
### ## 📝 Summary Makes `go.Box` (and `px.box`) plots fully selection-reactive in `mo.ui.plotly`. Users can now drag a box/lasso, click a box body, or click individual jittered points to get row-level data in Python. ## 🔍 Description of Changes - **Frontend:** added `type === "box"` to `shouldHandleClickSelection` so box click events are forwarded to Python. - **Python:** added `_append_box_points_to_selection` which handles three cases: range/lasso with individual points already sent by Plotly, range/lasso without points (extract from figure data), and click events (expand `pointNumbers` into individual rows). Supports vertical/horizontal orientation and categorical axes. - **Tests:** 12 Python tests + 1 frontend test covering all selection modes. - **Example:** `examples/third_party/plotly/box_chart.py` — three chart variants (single-trace, grouped, horizontal) each with a live stats summary and `mo.ui.table`. ## Selection <img width="1028" height="880" alt="box1" src="https://github.com/user-attachments/assets/284bdb5c-8dcd-4bbb-8067-0ef0bb991eae" /> ## Click Selection <img width="1018" height="782" alt="box2" src="https://github.com/user-attachments/assets/d38aaf9e-0704-4a9d-91d0-5ea978683db2" /> ## 📋 Pre-Review Checklist <!-- These checks need to be completed before a PR is reviewed --> - [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [x] Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it. - [x] Video or media evidence is provided for any visual changes (optional). <!-- PR is more likely to be merged if evidence is provided for changes made --> ## ✅ Merge Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [x] Documentation has been updated where applicable, including docstrings for API changes. - [x] Tests have been added for the changes made. @mscolnick @nojaf --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 64203be commit b73a370

9 files changed

Lines changed: 2882 additions & 280 deletions

File tree

docs/api/plotting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ alt.data_transformers.enable('marimo_csv')
106106

107107
marimo can render any Plotly plot, but [`mo.ui.plotly`][marimo.ui.plotly] only
108108
supports reactive selections for scatter/scattergl plots, pure line charts, bar charts,
109-
violin plots, histograms, waterfall charts, heatmaps, treemaps, and sunburst charts. If you require other kinds of
109+
box plots, violin plots, strip charts, histograms, funnel charts, funnelarea charts, waterfall charts, heatmaps, treemaps, and sunburst charts. If you require other kinds of
110110
selection, please [file an issue](https://github.com/marimo-team/marimo/issues).
111111

112112
::: marimo.ui.plotly
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
# /// script
2+
# requires-python = ">=3.10"
3+
# dependencies = [
4+
# "marimo",
5+
# "pandas==2.3.3",
6+
# "plotly==6.5.1",
7+
# ]
8+
# ///
9+
10+
import marimo
11+
12+
__generated_with = "0.20.2"
13+
app = marimo.App(width="medium")
14+
15+
16+
@app.cell
17+
def _():
18+
import marimo as mo
19+
import pandas as pd
20+
import plotly.express as px
21+
import plotly.graph_objects as go
22+
23+
return go, mo, pd, px
24+
25+
26+
@app.cell
27+
def _(mo):
28+
mo.md("""
29+
# Reactive Plotly Box Plot Selection
30+
31+
This example demonstrates reactive box plot selections with `mo.ui.plotly`.
32+
33+
Try:
34+
- **Drag a box** over jittered sample points to select them
35+
- **Click** a box element (median line, whisker, or box body) to select the
36+
entire group
37+
- Watch the table and stats below update from your selection
38+
39+
The `customdata` field embeds the original row ID (`sample_id`) so selected
40+
points map back to the source DataFrame without relying on raw indices.
41+
""")
42+
return
43+
44+
45+
@app.cell
46+
def _(pd):
47+
rows = [
48+
("ENG-01", "Engineering", "Day", "West", 72),
49+
("ENG-02", "Engineering", "Day", "West", 78),
50+
("ENG-03", "Engineering", "Day", "East", 81),
51+
("ENG-04", "Engineering", "Night", "East", 75),
52+
("ENG-05", "Engineering", "Night", "West", 84),
53+
("ENG-06", "Engineering", "Night", "Central", 89),
54+
("SAL-01", "Sales", "Day", "West", 61),
55+
("SAL-02", "Sales", "Day", "Central", 66),
56+
("SAL-03", "Sales", "Day", "East", 70),
57+
("SAL-04", "Sales", "Night", "West", 64),
58+
("SAL-05", "Sales", "Night", "East", 73),
59+
("SAL-06", "Sales", "Night", "Central", 77),
60+
("SUP-01", "Support", "Day", "East", 58),
61+
("SUP-02", "Support", "Day", "Central", 62),
62+
("SUP-03", "Support", "Day", "West", 68),
63+
("SUP-04", "Support", "Night", "East", 60),
64+
("SUP-05", "Support", "Night", "Central", 65),
65+
("SUP-06", "Support", "Night", "West", 71),
66+
]
67+
df = pd.DataFrame(rows, columns=["sample_id", "team", "shift", "region", "score"])
68+
df["passed"] = df["score"] >= 75
69+
return (df,)
70+
71+
72+
@app.cell
73+
def _():
74+
def selected_rows(selection, data):
75+
"""Map a mo.ui.plotly selection back to rows in the source DataFrame."""
76+
empty = data.iloc[0:0].copy()
77+
if not selection:
78+
return empty
79+
80+
# Prefer sample_id embedded via customdata.
81+
# Box-body clicks embed sample_id as customdata[0], not a top-level key.
82+
ids = []
83+
for row in selection:
84+
if isinstance(row.get("sample_id"), str):
85+
ids.append(row["sample_id"])
86+
else:
87+
cd = row.get("customdata")
88+
if isinstance(cd, (list, tuple)) and cd and isinstance(cd[0], str):
89+
ids.append(cd[0])
90+
if ids:
91+
return (
92+
data[data["sample_id"].isin(ids)]
93+
.drop_duplicates("sample_id")
94+
.sort_values("sample_id")
95+
)
96+
97+
# Fall back to pointIndex
98+
indices = sorted({
99+
row["pointIndex"]
100+
for row in selection
101+
if isinstance(row.get("pointIndex"), int)
102+
and 0 <= row["pointIndex"] < len(data)
103+
})
104+
if indices:
105+
return data.iloc[indices].copy()
106+
107+
return empty
108+
109+
return (selected_rows,)
110+
111+
112+
@app.cell
113+
def _(df, go, mo):
114+
mo.md("## Single-Trace Box Plot")
115+
116+
fig_single = go.Figure(
117+
data=go.Box(
118+
x=df["team"],
119+
y=df["score"],
120+
customdata=df[["sample_id", "shift", "region", "passed"]],
121+
boxpoints="all",
122+
jitter=0.35,
123+
pointpos=0,
124+
marker=dict(size=10, opacity=0.8, color="#1f77b4"),
125+
line=dict(color="#1f77b4"),
126+
hovertemplate=(
127+
"sample_id=%{customdata[0]}<br>"
128+
"team=%{x}<br>"
129+
"score=%{y}<br>"
130+
"shift=%{customdata[1]}<br>"
131+
"region=%{customdata[2]}<br>"
132+
"passed=%{customdata[3]}<extra></extra>"
133+
),
134+
name="score",
135+
)
136+
)
137+
fig_single.update_layout(
138+
title="Score by Team — drag over points or click a box",
139+
xaxis_title="Team",
140+
yaxis_title="Score",
141+
dragmode="select",
142+
)
143+
144+
box_single = mo.ui.plotly(fig_single)
145+
box_single
146+
return (box_single,)
147+
148+
149+
@app.cell
150+
def _(box_single, df, mo, selected_rows):
151+
_sel = selected_rows(box_single.value, df)
152+
153+
_summary = (
154+
f"{len(_sel)} rows selected — "
155+
f"avg score: {_sel['score'].mean():.1f} — "
156+
f"pass rate: {_sel['passed'].mean():.0%}"
157+
if not _sel.empty
158+
else "No rows selected yet."
159+
)
160+
161+
mo.md(f"""
162+
### Single-Trace Selection
163+
164+
**{_summary}**
165+
166+
**Indices:** {box_single.indices}
167+
""")
168+
return
169+
170+
171+
@app.cell
172+
def _(box_single, df, mo, selected_rows):
173+
mo.ui.table(selected_rows(box_single.value, df))
174+
return
175+
176+
177+
@app.cell
178+
def _(df, mo, px):
179+
mo.md("## Grouped Box Plot")
180+
181+
fig_grouped = px.box(
182+
df,
183+
x="team",
184+
y="score",
185+
color="shift",
186+
points="all",
187+
custom_data=["sample_id", "shift", "region", "passed"],
188+
title="Score by Team and Shift — drag to compare groups",
189+
)
190+
fig_grouped.update_traces(
191+
jitter=0.35,
192+
pointpos=0,
193+
hovertemplate=(
194+
"sample_id=%{customdata[0]}<br>"
195+
"team=%{x}<br>"
196+
"score=%{y}<br>"
197+
"shift=%{customdata[1]}<br>"
198+
"region=%{customdata[2]}<br>"
199+
"passed=%{customdata[3]}<extra></extra>"
200+
),
201+
)
202+
fig_grouped.update_layout(dragmode="select", xaxis_title="Team", yaxis_title="Score")
203+
204+
box_grouped = mo.ui.plotly(fig_grouped)
205+
box_grouped
206+
return (box_grouped,)
207+
208+
209+
@app.cell
210+
def _(box_grouped, df, mo, selected_rows):
211+
_sel_g = selected_rows(box_grouped.value, df)
212+
213+
_summary_g = (
214+
f"{len(_sel_g)} rows — "
215+
+ ", ".join(
216+
f"{team}: {count}"
217+
for team, count in _sel_g.groupby("team").size().items()
218+
)
219+
if not _sel_g.empty
220+
else "No rows selected yet."
221+
)
222+
223+
mo.md(f"""
224+
### Grouped Selection
225+
226+
**{_summary_g}**
227+
228+
**Indices:** {box_grouped.indices}
229+
230+
**Range:** {box_grouped.ranges}
231+
""")
232+
return
233+
234+
235+
@app.cell
236+
def _(box_grouped, df, mo, selected_rows):
237+
mo.ui.table(selected_rows(box_grouped.value, df))
238+
return
239+
240+
241+
@app.cell
242+
def _(df, go, mo):
243+
mo.md("## Horizontal Box Plot")
244+
245+
fig_horizontal = go.Figure(
246+
data=go.Box(
247+
x=df["score"],
248+
y=df["team"],
249+
orientation="h",
250+
customdata=df[["sample_id", "shift", "region", "passed"]],
251+
boxpoints="all",
252+
jitter=0.35,
253+
pointpos=0,
254+
marker=dict(size=10, opacity=0.8, color="#2a9d8f"),
255+
line=dict(color="#2a9d8f"),
256+
hovertemplate=(
257+
"sample_id=%{customdata[0]}<br>"
258+
"team=%{y}<br>"
259+
"score=%{x}<br>"
260+
"shift=%{customdata[1]}<br>"
261+
"region=%{customdata[2]}<br>"
262+
"passed=%{customdata[3]}<extra></extra>"
263+
),
264+
name="score",
265+
)
266+
)
267+
fig_horizontal.update_layout(
268+
title="Score by Team (horizontal) — same data, transposed",
269+
xaxis_title="Score",
270+
yaxis_title="Team",
271+
dragmode="select",
272+
)
273+
274+
box_horizontal = mo.ui.plotly(fig_horizontal)
275+
box_horizontal
276+
return (box_horizontal,)
277+
278+
279+
@app.cell
280+
def _(box_horizontal, df, mo, selected_rows):
281+
_sel_h = selected_rows(box_horizontal.value, df)
282+
283+
_summary_h = (
284+
f"{len(_sel_h)} rows — avg score: {_sel_h['score'].mean():.1f}"
285+
if not _sel_h.empty
286+
else "No rows selected yet."
287+
)
288+
289+
mo.md(f"""
290+
### Horizontal Selection
291+
292+
**{_summary_h}**
293+
294+
**Indices:** {box_horizontal.indices}
295+
""")
296+
return
297+
298+
299+
@app.cell
300+
def _(box_horizontal, df, mo, selected_rows):
301+
mo.ui.table(selected_rows(box_horizontal.value, df))
302+
return
303+
304+
305+
if __name__ == "__main__":
306+
app.run()

0 commit comments

Comments
 (0)