diff --git a/CHANGELOG.md b/CHANGELOG.md index c92c3ea6d6..2ec3060080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix Plotly Express mutating lists passed to the `x` or `y` arguments in wide mode [[#5727](https://github.com/plotly/plotly.py/pull/5727)], with thanks to @cpruijsen 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 Plotly Express misaligning per-point `opacity` values with their markers when the data is split into several traces (e.g. by `color`, `symbol` or facets); each trace now gets the opacities of its own points, and `opacity` also accepts a column name [[#5825](https://github.com/plotly/plotly.py/pull/5825)], with thanks to @shivenaggarwal for the contribution! ## [7.1.0] - 2026-09-15 diff --git a/plotly/express/_core.py b/plotly/express/_core.py index 9994720b09..50d3006b14 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -1,6 +1,7 @@ import plotly.graph_objs as go import plotly.io as pio from collections import namedtuple, OrderedDict +from numbers import Number from ._special_inputs import IdentityMap, Constant, Range from .trendline_functions import ols, lowess, rolling, expanding, ewm @@ -44,7 +45,11 @@ "pattern_shape", # renamed to marker.pattern.shape in infer_config ] all_attrables = ( - direct_attrables + array_attrables + group_attrables + renameable_group_attrables + direct_attrables + + array_attrables + + group_attrables + + renameable_group_attrables + + ["opacity"] # only refers to data when it isn't a number, see infer_config ) cartesians = [go.Scatter, go.Scattergl, go.Bar, go.Funnel, go.Box, go.Violin] @@ -352,6 +357,10 @@ def make_trace_kwargs(args, trace_spec, trace_data, mapping_labels, sizeref): trace_patch["marker"]["sizemode"] = "area" trace_patch["marker"]["sizeref"] = sizeref mapping_labels[attr_label] = "%{marker.size}" + elif attr_name == "opacity": + if "marker" not in trace_patch: + trace_patch["marker"] = dict() + trace_patch["marker"]["opacity"] = trace_data.get_column(attr_value) elif attr_name == "marginal_x": if trace_spec.constructor == go.Histogram and args.get("z") is None: mapping_labels["count"] = "%{y}" @@ -1313,6 +1322,8 @@ def process_args_into_dataframe( length = len(df_output[next(iter(df_output))]) if len(df_output) else 0 if argument is None: continue + if field_name == "opacity" and isinstance(argument, Number): + continue # a number is one opacity for all marks, not a column col_name = None # Case of multiindex if is_pd_like and isinstance(argument, native_namespace.MultiIndex): @@ -2373,6 +2384,10 @@ def infer_config(args, constructor, trace_patch, layout_patch): go.Funnelarea, ]: trace_patch["opacity"] = args["opacity"] + elif isinstance(args["opacity"], str): + # values from a column are split along with the rest of the data, + # so that each trace gets the opacities of its own marks + attrs.append("opacity") else: trace_patch["marker"] = dict(opacity=args["opacity"]) if ( diff --git a/tests/test_optional/test_px/test_px.py b/tests/test_optional/test_px/test_px.py index d4773de66f..ba52e5849d 100644 --- a/tests/test_optional/test_px/test_px.py +++ b/tests/test_optional/test_px/test_px.py @@ -382,6 +382,61 @@ def test_marginal_ranges(backend): assert fig.layout.yaxis3.range is None +@pytest.mark.parametrize( + "fn,kwargs", + [ + (px.scatter, dict(x="x", y="y")), + (px.bar, dict(x="x", y="y")), + (px.scatter_matrix, dict(dimensions=["x", "y"])), + ], +) +def test_opacity_values_split_by_color(constructor, fn, kwargs): + # https://github.com/plotly/plotly.py/issues/4580 + data = dict( + x=[1, 2, 3, 4, 5, 6], + y=[2, 3, 1, 5, 4, 6], + color=["a", "b", "a", "b", "a", "b"], + ) + opacity = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + fig = fn(constructor(data), color="color", opacity=opacity, **kwargs) + assert len(fig.data) == 2 + for trace in fig.data: + expected = [o for o, c in zip(opacity, data["color"]) if c == trace.name] + assert list(trace.marker.opacity) == expected + + +def test_opacity_column_split_by_facets(constructor): + data = dict( + x=[1, 2, 3, 4, 5, 6], + y=[2, 3, 1, 5, 4, 6], + row=["p", "p", "q", "q", "p", "q"], + col=["u", "v", "v", "u", "u", "v"], + alpha=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6], + ) + fig = px.scatter( + constructor(data), + x="x", + y="y", + facet_row="row", + facet_col="col", + opacity="alpha", + ) + assert len(fig.data) == 4 + alpha_by_x = dict(zip(data["x"], data["alpha"])) + for trace in fig.data: + assert list(trace.marker.opacity) == [alpha_by_x[x] for x in trace.x] + + +@pytest.mark.parametrize("opacity", [0.5, 1, np.float64(0.25)]) +def test_opacity_scalar(opacity): + # a number applies to every trace, even if a column has the same name + df = pd.DataFrame({0: [1, 2, 3], 1: [3, 1, 2], "color": ["a", "b", "a"]}) + fig = px.scatter(df, x=0, y=1, color="color", opacity=opacity) + assert len(fig.data) == 2 + for trace in fig.data: + assert trace.marker.opacity == opacity + + def test_render_mode(backend): df = nw.from_native(px.data.gapminder(return_type=backend)) df2007 = df.filter(nw.col("year") == 2007)