diff --git a/doc/users/next_whats_new/boxplot_legend_support.rst b/doc/users/next_whats_new/boxplot_legend_support.rst new file mode 100644 index 000000000000..44802960d9bb --- /dev/null +++ b/doc/users/next_whats_new/boxplot_legend_support.rst @@ -0,0 +1,60 @@ +Legend support for Boxplot +~~~~~~~~~~~~~~~~~~~~~~~~~~ +Boxplots now support a *label* parameter to create legend entries. + +Legend labels can be passed as a list of strings to label multiple boxes in a single +`.Axes.boxplot` call: + + +.. plot:: + :include-source: true + :alt: Example of creating 3 boxplots and assigning legend labels as a sequence. + + import matplotlib.pyplot as plt + import numpy as np + + np.random.seed(19680801) + fruit_weights = [ + np.random.normal(130, 10, size=100), + np.random.normal(125, 20, size=100), + np.random.normal(120, 30, size=100), + ] + labels = ['peaches', 'oranges', 'tomatoes'] + colors = ['peachpuff', 'orange', 'tomato'] + + fig, ax = plt.subplots() + ax.set_ylabel('fruit weight (g)') + + bplot = ax.boxplot(fruit_weights, + patch_artist=True, # fill with color + label=labels) + + # fill with colors + for patch, color in zip(bplot['boxes'], colors): + patch.set_facecolor(color) + + ax.set_xticks([]) + ax.legend() + + +Or as a single string to each individual `.Axes.boxplot`: + +.. plot:: + :include-source: true + :alt: Example of creating 2 boxplots and assigning each legend label as a string. + + import matplotlib.pyplot as plt + import numpy as np + + fig, ax = plt.subplots() + + data_A = np.random.random((100, 3)) + data_B = np.random.random((100, 3)) + 0.2 + pos = np.arange(3) + + ax.boxplot(data_A, positions=pos - 0.2, patch_artist=True, label='Box A', + boxprops={'facecolor': 'steelblue'}) + ax.boxplot(data_B, positions=pos + 0.2, patch_artist=True, label='Box B', + boxprops={'facecolor': 'lightblue'}) + + ax.legend() diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 627dd2c36d40..44d82184c3c8 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -3798,7 +3798,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None, tick_labels=None, flierprops=None, medianprops=None, meanprops=None, capprops=None, whiskerprops=None, manage_ticks=True, autorange=False, zorder=None, - capwidths=None): + capwidths=None, label=None): """ Draw a box and whisker plot. @@ -3985,6 +3985,20 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None, The style of the median. meanprops : dict, default: None The style of the mean. + label : str or list of str, optional + Legend labels. Use a single string when all boxes have the same style and + you only want a single legend entry for them. Use a list of strings to + label all boxes individually. To be distinguishable, the boxes should be + styled individually, which is currently only possible by modifying the + returned artists, see e.g. :doc:`/gallery/statistics/boxplot_demo`. + + In the case of a single string, the legend entry will technically be + associated with the first box only. By default, the legend will show the + median line (``result["medians"]``); if *patch_artist* is True, the legend + will show the box `.Patch` artists (``result["boxes"]``) instead. + + .. versionadded:: 3.9 + data : indexable object, optional DATA_PARAMETER_PLACEHOLDER @@ -4105,7 +4119,7 @@ def boxplot(self, x, notch=None, sym=None, vert=None, whis=None, meanline=meanline, showfliers=showfliers, capprops=capprops, whiskerprops=whiskerprops, manage_ticks=manage_ticks, zorder=zorder, - capwidths=capwidths) + capwidths=capwidths, label=label) return artists def bxp(self, bxpstats, positions=None, widths=None, vert=True, @@ -4114,7 +4128,7 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True, boxprops=None, whiskerprops=None, flierprops=None, medianprops=None, capprops=None, meanprops=None, meanline=False, manage_ticks=True, zorder=None, - capwidths=None): + capwidths=None, label=None): """ Draw a box and whisker plot from pre-computed statistics. @@ -4197,6 +4211,20 @@ def bxp(self, bxpstats, positions=None, widths=None, vert=True, If True, the tick locations and labels will be adjusted to match the boxplot positions. + label : str or list of str, optional + Legend labels. Use a single string when all boxes have the same style and + you only want a single legend entry for them. Use a list of strings to + label all boxes individually. To be distinguishable, the boxes should be + styled individually, which is currently only possible by modifying the + returned artists, see e.g. :doc:`/gallery/statistics/boxplot_demo`. + + In the case of a single string, the legend entry will technically be + associated with the first box only. By default, the legend will show the + median line (``result["medians"]``); if *patch_artist* is True, the legend + will show the box `.Patch` artists (``result["boxes"]``) instead. + + .. versionadded:: 3.9 + zorder : float, default: ``Line2D.zorder = 2`` The zorder of the resulting boxplot. @@ -4361,6 +4389,7 @@ def do_patch(xs, ys, **kwargs): if showbox: do_box = do_patch if patch_artist else do_plot boxes.append(do_box(box_x, box_y, **box_kw)) + median_kw.setdefault('label', '_nolegend_') # draw the whiskers whisker_kw.setdefault('label', '_nolegend_') whiskers.append(do_plot(whis_x, whislo_y, **whisker_kw)) @@ -4371,7 +4400,6 @@ def do_patch(xs, ys, **kwargs): caps.append(do_plot(cap_x, cap_lo, **cap_kw)) caps.append(do_plot(cap_x, cap_hi, **cap_kw)) # draw the medians - median_kw.setdefault('label', '_nolegend_') medians.append(do_plot(med_x, med_y, **median_kw)) # maybe draw the means if showmeans: @@ -4389,6 +4417,18 @@ def do_patch(xs, ys, **kwargs): flier_y = stats['fliers'] fliers.append(do_plot(flier_x, flier_y, **flier_kw)) + # Set legend labels + if label: + box_or_med = boxes if showbox and patch_artist else medians + if cbook.is_scalar_or_string(label): + # assign the label only to the first box + box_or_med[0].set_label(label) + else: # label is a sequence + if len(box_or_med) != len(label): + raise ValueError(datashape_message.format("label")) + for artist, lbl in zip(box_or_med, label): + artist.set_label(lbl) + if manage_ticks: axis_name = "x" if vert else "y" interval = getattr(self.dataLim, f"interval{axis_name}") diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index 18c10c1452ba..b70d330aa442 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -372,6 +372,7 @@ class Axes(_AxesBase): autorange: bool = ..., zorder: float | None = ..., capwidths: float | ArrayLike | None = ..., + label: Sequence[str] | None = ..., *, data=..., ) -> dict[str, Any]: ... @@ -397,6 +398,7 @@ class Axes(_AxesBase): manage_ticks: bool = ..., zorder: float | None = ..., capwidths: float | ArrayLike | None = ..., + label: Sequence[str] | None = ..., ) -> dict[str, Any]: ... def scatter( self, diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 04d889ec0616..81a6622db77e 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -2871,6 +2871,7 @@ def boxplot( autorange: bool = False, zorder: float | None = None, capwidths: float | ArrayLike | None = None, + label: Sequence[str] | None = None, *, data=None, ) -> dict[str, Any]: @@ -2902,6 +2903,7 @@ def boxplot( autorange=autorange, zorder=zorder, capwidths=capwidths, + label=label, **({"data": data} if data is not None else {}), ) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 3b3145006427..9b7a6dfd10c9 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1435,3 +1435,35 @@ def test_legend_text(): leg_bboxes.append( leg.get_window_extent().transformed(ax.transAxes.inverted())) assert_allclose(leg_bboxes[1].bounds, leg_bboxes[0].bounds) + + +def test_boxplot_legend_labels(): + # Test that legend entries are generated when passing `label`. + np.random.seed(19680801) + data = np.random.random((10, 4)) + fig, axs = plt.subplots(nrows=1, ncols=4) + legend_labels = ['box A', 'box B', 'box C', 'box D'] + + # Testing legend labels and patch passed to legend. + bp1 = axs[0].boxplot(data, patch_artist=True, label=legend_labels) + assert [v.get_label() for v in bp1['boxes']] == legend_labels + handles, labels = axs[0].get_legend_handles_labels() + assert labels == legend_labels + assert all(isinstance(h, mpl.patches.PathPatch) for h in handles) + + # Testing legend without `box`. + bp2 = axs[1].boxplot(data, label=legend_labels, showbox=False) + # Without a box, The legend entries should be passed from the medians. + assert [v.get_label() for v in bp2['medians']] == legend_labels + handles, labels = axs[1].get_legend_handles_labels() + assert labels == legend_labels + assert all(isinstance(h, mpl.lines.Line2D) for h in handles) + + # Testing legend with number of labels different from number of boxes. + with pytest.raises(ValueError, match='values must have same the length'): + bp3 = axs[2].boxplot(data, label=legend_labels[:-1]) + + # Test that for a string label, only the first box gets a label. + bp4 = axs[3].boxplot(data, label='box A') + assert bp4['medians'][0].get_label() == 'box A' + assert all(x.get_label().startswith("_") for x in bp4['medians'][1:])
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: