Skip to content

Commit ba24e66

Browse files
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.
1 parent 38374ac commit ba24e66

3 files changed

Lines changed: 72 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
1717
- 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!
1818
- 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!
1919
- 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!
20+
- 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!
2021

2122
## [7.1.0] - 2026-09-15
2223

‎plotly/express/_core.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import plotly.graph_objs as go
22
import plotly.io as pio
33
from collections import namedtuple, OrderedDict
4+
from numbers import Number
45
from ._special_inputs import IdentityMap, Constant, Range
56
from .trendline_functions import ols, lowess, rolling, expanding, ewm
67

@@ -44,7 +45,11 @@
4445
"pattern_shape", # renamed to marker.pattern.shape in infer_config
4546
]
4647
all_attrables = (
47-
direct_attrables + array_attrables + group_attrables + renameable_group_attrables
48+
direct_attrables
49+
+ array_attrables
50+
+ group_attrables
51+
+ renameable_group_attrables
52+
+ ["opacity"] # only refers to data when it isn't a number, see infer_config
4853
)
4954

5055
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):
352357
trace_patch["marker"]["sizemode"] = "area"
353358
trace_patch["marker"]["sizeref"] = sizeref
354359
mapping_labels[attr_label] = "%{marker.size}"
360+
elif attr_name == "opacity":
361+
if "marker" not in trace_patch:
362+
trace_patch["marker"] = dict()
363+
trace_patch["marker"]["opacity"] = trace_data.get_column(attr_value)
355364
elif attr_name == "marginal_x":
356365
if trace_spec.constructor == go.Histogram and args.get("z") is None:
357366
mapping_labels["count"] = "%{y}"
@@ -1313,6 +1322,8 @@ def process_args_into_dataframe(
13131322
length = len(df_output[next(iter(df_output))]) if len(df_output) else 0
13141323
if argument is None:
13151324
continue
1325+
if field_name == "opacity" and isinstance(argument, Number):
1326+
continue # a number is one opacity for all marks, not a column
13161327
col_name = None
13171328
# Case of multiindex
13181329
if is_pd_like and isinstance(argument, native_namespace.MultiIndex):
@@ -2373,6 +2384,10 @@ def infer_config(args, constructor, trace_patch, layout_patch):
23732384
go.Funnelarea,
23742385
]:
23752386
trace_patch["opacity"] = args["opacity"]
2387+
elif isinstance(args["opacity"], str):
2388+
# values from a column are split along with the rest of the data,
2389+
# so that each trace gets the opacities of its own marks
2390+
attrs.append("opacity")
23762391
else:
23772392
trace_patch["marker"] = dict(opacity=args["opacity"])
23782393
if (

‎tests/test_optional/test_px/test_px.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,61 @@ def test_marginal_ranges(backend):
382382
assert fig.layout.yaxis3.range is None
383383

384384

385+
@pytest.mark.parametrize(
386+
"fn,kwargs",
387+
[
388+
(px.scatter, dict(x="x", y="y")),
389+
(px.bar, dict(x="x", y="y")),
390+
(px.scatter_matrix, dict(dimensions=["x", "y"])),
391+
],
392+
)
393+
def test_opacity_values_split_by_color(constructor, fn, kwargs):
394+
# https://github.com/plotly/plotly.py/issues/4580
395+
data = dict(
396+
x=[1, 2, 3, 4, 5, 6],
397+
y=[2, 3, 1, 5, 4, 6],
398+
color=["a", "b", "a", "b", "a", "b"],
399+
)
400+
opacity = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
401+
fig = fn(constructor(data), color="color", opacity=opacity, **kwargs)
402+
assert len(fig.data) == 2
403+
for trace in fig.data:
404+
expected = [o for o, c in zip(opacity, data["color"]) if c == trace.name]
405+
assert list(trace.marker.opacity) == expected
406+
407+
408+
def test_opacity_column_split_by_facets(constructor):
409+
data = dict(
410+
x=[1, 2, 3, 4, 5, 6],
411+
y=[2, 3, 1, 5, 4, 6],
412+
row=["p", "p", "q", "q", "p", "q"],
413+
col=["u", "v", "v", "u", "u", "v"],
414+
alpha=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6],
415+
)
416+
fig = px.scatter(
417+
constructor(data),
418+
x="x",
419+
y="y",
420+
facet_row="row",
421+
facet_col="col",
422+
opacity="alpha",
423+
)
424+
assert len(fig.data) == 4
425+
alpha_by_x = dict(zip(data["x"], data["alpha"]))
426+
for trace in fig.data:
427+
assert list(trace.marker.opacity) == [alpha_by_x[x] for x in trace.x]
428+
429+
430+
@pytest.mark.parametrize("opacity", [0.5, 1, np.float64(0.25)])
431+
def test_opacity_scalar(opacity):
432+
# a number applies to every trace, even if a column has the same name
433+
df = pd.DataFrame({0: [1, 2, 3], 1: [3, 1, 2], "color": ["a", "b", "a"]})
434+
fig = px.scatter(df, x=0, y=1, color="color", opacity=opacity)
435+
assert len(fig.data) == 2
436+
for trace in fig.data:
437+
assert trace.marker.opacity == opacity
438+
439+
385440
def test_render_mode(backend):
386441
df = nw.from_native(px.data.gapminder(return_type=backend))
387442
df2007 = df.filter(nw.col("year") == 2007)

0 commit comments

Comments
 (0)