From 4417d3d54ef6e504f0a79693a7d3be86f6edc6dd Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Mon, 27 Apr 2026 08:42:25 +1000 Subject: [PATCH] Preserve errorbar uncertainty glyphs in mean-plot legends. This keeps errorbar-based uncertainty styles in the legend path instead of collapsing them down to the mean line, so barstd and boxstd now match the rendered plot the same way shaded uncertainty styles already do. The accompanying regression checks both the parsed handles and the rendered legend artists so future legend changes do not silently drop the uncertainty glyph again. --- ultraplot/axes/base.py | 7 +++---- ultraplot/tests/test_legend.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/ultraplot/axes/base.py b/ultraplot/axes/base.py index 29a962e14..43253ea65 100644 --- a/ultraplot/axes/base.py +++ b/ultraplot/axes/base.py @@ -2127,14 +2127,11 @@ def _legend_label(*objs): # noqa: E301 # Helper function. Translate handles in the input tuple group. Extracts # legend handles from contour sets and extracts labeled elements from # matplotlib containers (important for histogram plots). - ignore = (mcontainer.ErrorbarContainer,) containers = (cbook.silent_list, mcontainer.Container) def _legend_tuple(*objs): # noqa: E306 handles = [] for obj in objs: - if isinstance(obj, ignore) and not _legend_label(obj): - continue if hasattr(obj, "update_scalarmappable"): # for e.g. pcolor obj.update_scalarmappable() if isinstance(obj, mcontour.ContourSet): # extract single element @@ -2143,7 +2140,9 @@ def _legend_tuple(*objs): # noqa: E306 if hs: # non-empty obj = hs[len(hs) // 2] obj.set_label(label) - if isinstance(obj, containers): # extract labeled elements + if isinstance(obj, mcontainer.ErrorbarContainer): + handles.append(obj) + elif isinstance(obj, containers): # extract labeled elements hs = (obj, *guides._iter_iterables(obj)) hs = tuple(filter(_legend_label, hs)) if hs: diff --git a/ultraplot/tests/test_legend.py b/ultraplot/tests/test_legend.py index 43fae2a81..d9971fe16 100644 --- a/ultraplot/tests/test_legend.py +++ b/ultraplot/tests/test_legend.py @@ -1,12 +1,16 @@ import numpy as np import pandas as pd import pytest +from matplotlib import collections as mcollections from matplotlib import colors as mcolors +from matplotlib import container as mcontainer from matplotlib import legend_handler as mhandler +from matplotlib import lines as mlines from matplotlib import patches as mpatches import ultraplot as uplt from ultraplot.axes import Axes as UAxes +from ultraplot.internals import guides @pytest.mark.mpl_image_compare @@ -183,6 +187,28 @@ def test_tuple_handles(rng): return fig +@pytest.mark.parametrize("kwarg", ["barstd", "boxstd"]) +def test_mean_errorbar_handles_are_preserved_in_legends(kwarg, rng): + fig, axs = uplt.subplots() + ax = axs[0] + data = rng.random((10, 4)).cumsum(axis=0) + + handles = ax.plot(data, means=True, label="label", **{kwarg: 1}) + handles, labels = ax._parse_legend_group(handles, None) + + assert labels == ["label"] + assert len(handles) == 1 + assert isinstance(handles[0], tuple) + assert any(isinstance(obj, mcontainer.ErrorbarContainer) for obj in handles[0]) + + leg = ax.legend(handles) + legend_children = list(guides._iter_children(leg._legend_handle_box)) + assert any(isinstance(obj, mcollections.LineCollection) for obj in legend_children) + assert any(isinstance(obj, mlines.Line2D) for obj in legend_children) + + uplt.close(fig) + + @pytest.mark.mpl_image_compare def test_legend_col_spacing(rng): """