Skip to content
Open
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 @@ -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!

Expand Down
50 changes: 50 additions & 0 deletions plotly/express/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions tests/test_optional/test_px/test_px.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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