From ba24e6678ad3224ecfdbbc5cfe86ab518650fd77 Mon Sep 17 00:00:00 2001 From: Shiven Aggarwal Date: Sun, 18 Oct 2026 11:36:54 +0530 Subject: [PATCH] Fix px opacity values misaligned when points are split into traces When `opacity` was given one value per point, Plotly Express passed the whole array to every trace, so once points were split by color, symbol, facets or animation frames the values no longer matched their markers. A non-numeric `opacity` is now handled like the other data arguments: it is added to the data frame, split along with the rest of the data and set as `marker.opacity` on each trace. This also makes column names work. A number still applies to all marks, as before. --- CHANGELOG.md | 1 + plotly/express/_core.py | 17 +++++++- tests/test_optional/test_px/test_px.py | 55 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c92c3ea6d64..2ec30600806 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 9994720b094..50d3006b149 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 d4773de66f8..ba52e5849d2 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)