Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Fix `convert_dash` mapping dotted lines (`linestyle=':'`) to invalid `'circle'` in Plotly, correctly converting them to `'dot'` and preserving custom dash patterns as pixel lists, [[#5719](https://github.com/plotly/plotly.py/pull/5719)] with thanks to @robertoffmoura for the contribution!
- Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution!
- Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution!
- Fix serialization of box/lasso select data for figures with multiple subplots [[#5710](https://github.com/plotly/plotly.py/pull/5710)], with thanks to @flpi-sun for the contribution!

## [7.1.0] - 2026-09-15

Expand Down
39 changes: 9 additions & 30 deletions js/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,7 @@ type Py2JsUpdateMsg = Py2JsMsg & {

type Selector = {
type: "box" | "lasso";
selector_state:
| { xrange: number[]; yrange: number[] }
| { xs: number[]; ys: number[] };
selector_state: Record<string, any>;
};

// Model
Expand Down Expand Up @@ -465,29 +463,14 @@ export class FigureModel {
* 2 (secondary), and 4 (auxilary) were pressed during the event
*/

/**
* @typedef {Object} BoxSelectorState
* @property {Array.<Number>} xrange
* Two element array containing the x-range of the box selection
* @property {Array.<Number>} yrange
* Two element array containing the y-range of the box selection
*/

/**
* @typedef {Object} LassoSelectorState
* @property {Array.<Number>} xs
* Array of the x-coordinates of the lasso selection region
* @property {Array.<Number>} ys
* Array of the y-coordinates of the lasso selection region
*/

/**
* Object representing the state of the selection tool during a
* plotly_select event
* @typedef {Object} Selector
* @property {String} type
* Selection type. One of: 'box', or 'lasso'
* @property {BoxSelectorState|LassoSelectorState} selector_state
* @property {Object.<String, Array>} selector_state
* Selection geometry, keyed by plotly.js axis id
*/

/**
Expand Down Expand Up @@ -1149,22 +1132,18 @@ export class FigureView {
var selectorObject: Selector;

if (data.hasOwnProperty("range")) {
// Box selection
// Box selection - preserve all subplot-specific axis keys (x, x2, x3, ... and y, y2, y3, ...)
var rangeData = data["range"];
selectorObject = {
type: "box",
selector_state: {
xrange: data["range"]["x"],
yrange: data["range"]["y"],
},
selector_state: rangeData,
};
} else if (data.hasOwnProperty("lassoPoints")) {
// Lasso selection
// Lasso selection - preserve all subplot-specific axis keys
var lassoData = data["lassoPoints"];
selectorObject = {
type: "lasso",
selector_state: {
xs: data["lassoPoints"]["x"],
ys: data["lassoPoints"]["y"],
},
selector_state: lassoData,
};
} else {
selectorObject = null;
Expand Down
47 changes: 33 additions & 14 deletions plotly/basewidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@
import anywidget


def _selector_for_trace(selector_data, trace):
"""
Return the selection as seen from one trace.

Plotly.js keys the payload by axis id, so a selection in the second
subplot arrives as {"x2": [...], "y2": [...]}. A trace drawn on another
subplot gets None, because that trace was not selected.
"""
state = selector_data["selector_state"]
xaxis = getattr(trace, "xaxis", None) or "x"
yaxis = getattr(trace, "yaxis", None) or "y"

# "xrange"/"yrange" and "xs"/"ys" come from an older frontend bundle.
selector_type = selector_data["type"]
if selector_type == "box":
return BoxSelector(
xrange=state.get("xrange", state.get(xaxis)),
yrange=state.get("yrange", state.get(yaxis)),
)
elif selector_type == "lasso":
return LassoSelector(
xs=state.get("xs", state.get(xaxis)),
ys=state.get("ys", state.get(yaxis)),
)
raise ValueError("Unsupported selector type: %s" % selector_type)


class BaseFigureWidget(BaseFigure, anywidget.AnyWidget):
"""
Base class for FigureWidget. The FigureWidget class is code-generated as a
Expand Down Expand Up @@ -37,7 +64,7 @@ class BaseFigureWidget(BaseFigure, anywidget.AnyWidget):
# appropriate _py2js_* property and then immediately assigning None to the
# property.
#
# See JSDoc comments in the FigureModel class in js/src/Figure.js for
# See JSDoc comments in the FigureModel class in js/src/widget.ts for
# detailed descriptions of the messages.
_py2js_addTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers)
_py2js_restyle = Dict(allow_none=True).tag(sync=True, **custom_serializers)
Expand All @@ -61,7 +88,7 @@ class BaseFigureWidget(BaseFigure, anywidget.AnyWidget):
# responsible for setting the message property to None after retreiving
# the message data.
#
# See JSDoc comments in the FigureModel class in js/src/Figure.js for
# See JSDoc comments in the FigureModel class in js/src/widget.ts for
# detailed descriptions of the messages.
_js2py_traceDeltas = Dict(allow_none=True).tag(sync=True, **custom_serializers)
_js2py_layoutDelta = Dict(allow_none=True).tag(sync=True, **custom_serializers)
Expand Down Expand Up @@ -651,18 +678,7 @@ def _handler_js2py_pointsCallback(self, change):

# Build Selector Object
# ---------------------
if callback_data.get("selector", None):
selector_data = callback_data["selector"]
selector_type = selector_data["type"]
selector_state = selector_data["selector_state"]
if selector_type == "box":
selector = BoxSelector(**selector_state)
elif selector_type == "lasso":
selector = LassoSelector(**selector_state)
else:
raise ValueError("Unsupported selector type: %s" % selector_type)
else:
selector = None
selector_data = callback_data.get("selector", None)

# Build Input Device State Object
# -------------------------------
Expand Down Expand Up @@ -710,6 +726,9 @@ def _handler_js2py_pointsCallback(self, change):
elif event_type == "plotly_unhover":
trace._dispatch_on_unhover(points, state)
elif event_type == "plotly_selected":
selector = (
_selector_for_trace(selector_data, trace) if selector_data else None
)
trace._dispatch_on_selection(points, selector)
elif event_type == "plotly_deselect":
trace._dispatch_on_deselect(points)
Expand Down
46 changes: 23 additions & 23 deletions plotly/package_data/widgetbundle.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""
Tests for the selection data that reaches an on_selection callback.

Plotly.js keys box and lasso selection data by axis id, so a selection in
the second subplot arrives as {"x2": [...], "y2": [...]}. The widget
resolves that payload against each trace's own axes.
"""

import pytest

import plotly.graph_objs as go
from plotly.subplots import make_subplots

try:
go.FigureWidget()
except ImportError:
pytest.skip("FigureWidget is unavailable", allow_module_level=True)


def two_subplots():
"""Return a widget with one trace on x/y and another on x2/y2."""
fig = make_subplots(rows=1, cols=2)
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[2, 1, 3]), row=1, col=1)
fig.add_trace(go.Scatter(x=[10, 11, 20, 21], y=[1, 2, 3, 4]), row=1, col=2)
return go.FigureWidget(fig)


def select(widget, selector_state, selector_type="box"):
"""
Fire a selection and return the selector each trace received.

The selection carries no points, because a box can cover a region of
the figure that holds no data. That is the case the ranges matter most.
"""
selectors = []
for trace in widget.data:
trace.on_selection(lambda t, p, selector: selectors.append(selector))

widget._js2py_pointsCallback = {
"event_type": "plotly_selected",
"points": {"xs": [], "ys": [], "point_indexes": [], "trace_indexes": []},
"device_state": None,
"selector": {"type": selector_type, "selector_state": selector_state},
}
return selectors


def test_box_selection_in_the_first_subplot():
first, second = select(two_subplots(), {"x": [1.1, 2.9], "y": [0.5, 3.5]})

assert first.xrange == [1.1, 2.9]
assert first.yrange == [0.5, 3.5]
# The other subplot was not selected, so it reports nothing.
assert second.xrange is None
assert second.yrange is None


def test_box_selection_in_the_second_subplot():
first, second = select(two_subplots(), {"x2": [12.0, 19.0], "y2": [0.0, 5.0]})

assert second.xrange == [12.0, 19.0]
assert second.yrange == [0.0, 5.0]
assert first.xrange is None
assert first.yrange is None


def test_box_selection_in_a_figure_without_subplots():
widget = go.FigureWidget(go.Scatter(x=[1, 2, 3], y=[3, 2, 1]))

(only,) = select(widget, {"x": [1.0, 2.0], "y": [1.5, 3.0]})

assert only.xrange == [1.0, 2.0]
assert only.yrange == [1.5, 3.0]


def test_lasso_selection_in_the_second_subplot():
first, second = select(two_subplots(), {"x2": [1, 2, 3], "y2": [4, 5, 6]}, "lasso")

assert second.xs == [1, 2, 3]
assert second.ys == [4, 5, 6]
assert first.xs is None
assert first.ys is None


def test_older_frontend_sends_xrange_and_yrange():
first, _ = select(two_subplots(), {"xrange": [1.0, 2.0], "yrange": [1.5, 3.0]})

assert first.xrange == [1.0, 2.0]
assert first.yrange == [1.5, 3.0]


def test_older_frontend_sends_xs_and_ys():
first, _ = select(two_subplots(), {"xs": [1, 2, 3], "ys": [4, 5, 6]}, "lasso")

assert first.xs == [1, 2, 3]
assert first.ys == [4, 5, 6]
Loading