From d3e89bc9e13d5704484b7e6f47dbd0a3ba6eacc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Furga=C5=82a?= <83299832+00200200@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:05:45 +0200 Subject: [PATCH] Fix px error bars ignoring opacity on categorical traces. --- CHANGELOG.md | 1 + plotly/express/_core.py | 50 ++++++++++++++++++ tests/test_optional/test_px/test_px.py | 71 ++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0583fec45c8..1e324777844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased ### Fixed +- Fix Plotly Express error bars ignoring `opacity` on categorically colored traces. Error bars now use the marker/line color with the requested opacity baked in [[#4353](https://github.com/plotly/plotly.py/issues/4353)], with thanks to @00200200 for the contribution! - Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#3441](https://github.com/plotly/plotly.py/issues/3441)], with thanks to @hb1915 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 [[#5765](https://github.com/plotly/plotly.py/issues/5765)], with thanks to @Irahan2 for the contribution! diff --git a/plotly/express/_core.py b/plotly/express/_core.py index e99bf40b874..78bba5f65fb 100644 --- a/plotly/express/_core.py +++ b/plotly/express/_core.py @@ -5,6 +5,7 @@ from .trendline_functions import ols, lowess, rolling, expanding, ewm from _plotly_utils.basevalidators import ColorscaleValidator +from _plotly_utils.colors import hex_to_rgb, unlabel_rgb from plotly.colors import qualitative, sequential import math @@ -2529,6 +2530,53 @@ def get_groups_and_orders(args, grouper): return groups, orders +def _color_with_opacity(color, opacity): + """Return an ``rgba()`` string mixing ``color`` with ``opacity``. + + Plotly.js error bars have a ``color`` property but no ``opacity``, so the + marker/line opacity has to be baked into the error-bar color. Returns + ``None`` when ``color`` is missing or not a parseable hex/rgb string + (named CSS colors are left unchanged by the caller). + """ + if not isinstance(color, str): + return None + color = color.strip() + try: + if color.startswith("#"): + r, g, b = hex_to_rgb(color) + elif "rgb" in color.lower(): + r, g, b = unlabel_rgb(color) + else: + return None + return f"rgba({int(r)}, {int(g)}, {int(b)}, {opacity})" + except (TypeError, ValueError, IndexError): + return None + + +def _apply_error_bar_opacity(trace, opacity): + """Copy marker/line color through to error bars, including ``opacity``. + + Categorical Plotly Express traces already give error bars the marker + color via Plotly.js inheritance, but ``marker.opacity`` is ignored. + Continuous color (numeric ``marker.color`` arrays) cannot be mapped + onto ``error_y.color``, which is a single color in the schema. + """ + color = getattr(trace.marker, "color", None) + if not isinstance(color, str): + color = getattr(trace.line, "color", None) + rgba = _color_with_opacity(color, opacity) + if rgba is None: + return + for name in ("error_x", "error_y", "error_z"): + err = getattr(trace, name, None) + if err is None: + continue + if err.array is None and err.arrayminus is None: + continue + if err.color is None: + err.color = rgba + + def make_figure(args, constructor, trace_patch=None, layout_patch=None): trace_patch = trace_patch or {} layout_patch = layout_patch or {} @@ -2729,6 +2777,8 @@ def make_figure(args, constructor, trace_patch=None, layout_patch=None): args, trace_spec, group, mapping_labels.copy(), sizeref ) trace.update(patch) + if args.get("opacity") is not None: + _apply_error_bar_opacity(trace, args["opacity"]) if fit_results is not None: trendline_rows.append(mapping_labels.copy()) trendline_rows[-1]["px_fit_results"] = fit_results diff --git a/tests/test_optional/test_px/test_px.py b/tests/test_optional/test_px/test_px.py index d4773de66f8..a9131d5a1ce 100644 --- a/tests/test_optional/test_px/test_px.py +++ b/tests/test_optional/test_px/test_px.py @@ -4,6 +4,7 @@ import pandas as pd import plotly.express as px import plotly.io as pio +from plotly.colors import hex_to_rgb import narwhals.stable.v1 as nw import numpy as np import pytest @@ -473,3 +474,73 @@ def test_no_warn_on_update_template(): with warnings.catch_warnings(): warnings.simplefilter("error") fig.update_layout(template="plotly_white") + + +def test_error_bars_inherit_marker_opacity(): + # https://github.com/plotly/plotly.py/issues/4353 + df = px.data.iris() + df = df.assign(e_plus=df["sepal_width"] / 100, e_minus=df["sepal_width"] / 40) + opacity = 0.6 + fig = px.scatter( + df, + x="sepal_width", + y="sepal_length", + color="species", + error_y="e_plus", + error_y_minus="e_minus", + opacity=opacity, + ) + assert len(fig.data) == 3 + for trace in fig.data: + assert trace.marker.opacity == opacity + r, g, b = hex_to_rgb(trace.marker.color) + assert trace.error_y.color == f"rgba({r}, {g}, {b}, {opacity})" + + +def test_error_bars_keep_inherited_color_without_opacity(): + # Without opacity, leave error_y.color unset so Plotly.js still inherits + # the marker color (the pre-fix behavior for categorical traces). + df = px.data.iris() + df = df.assign(e=df["sepal_width"] / 100) + fig = px.scatter( + df, x="sepal_width", y="sepal_length", color="species", error_y="e" + ) + for trace in fig.data: + assert trace.error_y.color is None + assert trace.error_y.array is not None + + +def test_error_x_bars_inherit_marker_opacity(): + df = px.data.iris() + df = df.assign(e=df["sepal_width"] / 100) + opacity = 0.4 + fig = px.scatter( + df, + x="sepal_width", + y="sepal_length", + color="species", + error_x="e", + opacity=opacity, + ) + for trace in fig.data: + r, g, b = hex_to_rgb(trace.marker.color) + assert trace.error_x.color == f"rgba({r}, {g}, {b}, {opacity})" + assert trace.error_y.color is None + + +def test_error_bars_opacity_with_continuous_color(): + # error_y.color is a single color in the schema, so numeric color arrays + # cannot be mapped onto error bars. Opacity must not crash this path. + df = px.data.iris() + df = df.assign(e=df["sepal_width"] / 100) + fig = px.scatter( + df, + x="sepal_width", + y="sepal_length", + color="petal_width", + error_y="e", + opacity=0.6, + ) + assert fig.data[0].marker.opacity == 0.6 + assert fig.data[0].error_y.array is not None + assert fig.data[0].error_y.color is None