diff --git a/doc/api/next_api_changes/behavior/29152_REC.rst b/doc/api/next_api_changes/behavior/29152_REC.rst new file mode 100644 index 000000000000..8e6c96fe0e97 --- /dev/null +++ b/doc/api/next_api_changes/behavior/29152_REC.rst @@ -0,0 +1,9 @@ +Return value of ``pie`` +~~~~~~~~~~~~~~~~~~~~~~~ +Previously, if no *labels* were passed to `~.Axes.pie`, and *labeldistance* was +not ``None``, empty text labels would be added to the axes and returned. This +is no longer the case. To continue creating empty labels, either pass an empty +string with the new *wedge_labels* parameter ``wedge_labels=''`` or, for +compatibility with older Matplotlib versions, pass an empty string for each wedge +via the *labels* parameter, i.e. ``labels=[''] * number_of_wedges``. Note the +latter option will stop working at Matplotlib 3.16. diff --git a/doc/api/next_api_changes/deprecations/29152_REC.rst b/doc/api/next_api_changes/deprecations/29152_REC.rst new file mode 100644 index 000000000000..477fef60bd18 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/29152_REC.rst @@ -0,0 +1,10 @@ +``pie`` *labels* and *labeldistance* parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Currently the *labels* parameter of `~.Axes.pie` is used both for annotating the +pie wedges directly, and for automatic legend entries. For consistency +with other plotting methods, in future *labels* will only be used for the legend. + +The *labeldistance* parameter will therefore default to ``None`` from Matplotlib +3.14, when it will also be deprecated and then removed in Matplotlib 3.16. To +preserve the existing behavior for now, set ``labeldistance=1.1``. For longer +term, use the new *wedge_labels* parameter of `~.Axes.pie` instead of *labels*. diff --git a/doc/release/next_whats_new/pie_wedge_labels.rst b/doc/release/next_whats_new/pie_wedge_labels.rst new file mode 100644 index 000000000000..7cb79df69c4a --- /dev/null +++ b/doc/release/next_whats_new/pie_wedge_labels.rst @@ -0,0 +1,18 @@ +New *wedge_labels* parameter for pie +------------------------------------ + +`~.Axes.pie` now accepts a *wedge_labels* parameter which may be used to +annotate the wedges of the pie chart. It can take + +* a list of strings, similar to the existing *labels* parameter +* a format string in analogy to the existing *autopct* parameter except that it + uses the `str.format` method, and it can handle absolute values as well as + fractions/percentages + +To add multiple labels per wedge, *wedge_labels* can take a sequence of any combination +of the above two options. + +*wedge_labels* have accompanying *wedge_label_distance* and *rotate_wedge_labels* +parameters, to customise the position and rotation of the labels. + +For examples, see :doc:`/gallery/pie_and_polar_charts/pie_features`. diff --git a/galleries/examples/misc/svg_filter_pie.py b/galleries/examples/misc/svg_filter_pie.py index b19867be9a2f..e9cf8604fa02 100644 --- a/galleries/examples/misc/svg_filter_pie.py +++ b/galleries/examples/misc/svg_filter_pie.py @@ -28,11 +28,12 @@ # We want to draw the shadow for each pie, but we will not use "shadow" # option as it doesn't save the references to the shadow patches. -pies = ax.pie(fracs, explode=explode, labels=labels, autopct='%1.1f%%') +pies = ax.pie(fracs, explode=explode, + wedge_labels=[labels, '{frac:.1%}'], wedge_label_distance=[1.1, 0.6]) -for w in pies[0]: +for w, label in zip(pies[0], labels): # set the id with the label. - w.set_gid(w.get_label()) + w.set_gid(label) # we don't want to draw the edge of the pie w.set_edgecolor("none") diff --git a/galleries/examples/pie_and_polar_charts/bar_of_pie.py b/galleries/examples/pie_and_polar_charts/bar_of_pie.py index 6f18b964cef7..0e5049bcf901 100644 --- a/galleries/examples/pie_and_polar_charts/bar_of_pie.py +++ b/galleries/examples/pie_and_polar_charts/bar_of_pie.py @@ -25,8 +25,9 @@ explode = [0.1, 0, 0] # rotate so that first wedge is split by the x-axis angle = -180 * overall_ratios[0] -wedges, *_ = ax1.pie(overall_ratios, autopct='%1.1f%%', startangle=angle, - labels=labels, explode=explode) +wedges, *_ = ax1.pie( + overall_ratios, startangle=angle, explode=explode, + wedge_labels=[labels, '{frac:.1%}'], wedge_label_distance=[1.1, 0.6]) # bar chart parameters age_ratios = [.33, .54, .07, .06] diff --git a/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py b/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py index 13e3019bc7ba..c18aaf751064 100644 --- a/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py +++ b/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py @@ -15,9 +15,9 @@ # Now it's time for the pie. Starting with a pie recipe, we create the data # and a list of labels from it. # -# We can provide a function to the ``autopct`` argument, which will expand -# automatic percentage labeling by showing absolute values; we calculate -# the latter back from relative data and the known sum of all values. +# We can provide a format string to the *wedge_labels* parameter, to +# automatically label each ingredient's wedge with its weight in grams and +# percentages. # # We then create the pie and store the returned objects for later. The first # returned element of the returned tuple is a list of the wedges. Those are @@ -31,32 +31,24 @@ import matplotlib.pyplot as plt import numpy as np -fig, ax = plt.subplots(figsize=(6, 3), subplot_kw=dict(aspect="equal")) +fig, ax = plt.subplots(figsize=(6, 3)) recipe = ["375 g flour", "75 g sugar", "250 g butter", "300 g berries"] -data = [float(x.split()[0]) for x in recipe] +data = [int(x.split()[0]) for x in recipe] ingredients = [x.split()[-1] for x in recipe] - -def func(pct, allvals): - absolute = int(np.round(pct/100.*np.sum(allvals))) - return f"{pct:.1f}%\n({absolute:d} g)" - - -wedges, texts, autotexts = ax.pie(data, autopct=lambda pct: func(pct, data), - textprops=dict(color="w")) +wedges, texts = ax.pie(data, wedge_labels='{frac:.1%}\n({absval:d}g)', + textprops=dict(color="w", size=8, weight="bold")) ax.legend(wedges, ingredients, title="Ingredients", loc="center left", bbox_to_anchor=(1, 0, 0.5, 1)) -plt.setp(autotexts, size=8, weight="bold") - ax.set_title("Matplotlib bakery: A pie") plt.show() @@ -97,7 +89,7 @@ def func(pct, allvals): data = [225, 90, 50, 60, 100, 5] -wedges, texts = ax.pie(data, wedgeprops=dict(width=0.5), startangle=-40) +wedges, _ = ax.pie(data, wedgeprops=dict(width=0.5), startangle=-40) bbox_props = dict(boxstyle="square,pad=0.3", fc="w", ec="k", lw=0.72) kw = dict(arrowprops=dict(arrowstyle="-"), diff --git a/galleries/examples/pie_and_polar_charts/pie_features.py b/galleries/examples/pie_and_polar_charts/pie_features.py index 4c0eeaa4526e..9aaec1bd71d2 100644 --- a/galleries/examples/pie_and_polar_charts/pie_features.py +++ b/galleries/examples/pie_and_polar_charts/pie_features.py @@ -15,15 +15,15 @@ # ------------ # # Plot a pie chart of animals and label the slices. To add -# labels, pass a list of labels to the *labels* parameter +# labels, pass a list of labels to the *wedge_labels* parameter. import matplotlib.pyplot as plt labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' -sizes = [15, 30, 45, 10] +sizes = [12, 24, 36, 8] fig, ax = plt.subplots() -ax.pie(sizes, labels=labels) +ax.pie(sizes, wedge_labels=labels) # %% # Each slice of the pie chart is a `.patches.Wedge` object; therefore in @@ -31,16 +31,51 @@ # the *wedgeprops* argument, as demonstrated in # :doc:`/gallery/pie_and_polar_charts/nested_pie`. # +# Controlling label positions +# --------------------------- +# If you want the labels outside the pie, set a *wedge_label_distance* greater than 1. +# This is the distance from the center of the pie as a fraction of its radius. + +fig, ax = plt.subplots() +ax.pie(sizes, wedge_labels=labels, wedge_label_distance=1.1) + +# %% +# # Auto-label slices # ----------------- # -# Pass a function or format string to *autopct* to label slices. +# Pass a format string to *wedge_labels* to label slices with their values + +fig, ax = plt.subplots() +ax.pie(sizes, wedge_labels='{absval:.1f}') + +# %% +# +# or with their percentages + +fig, ax = plt.subplots() +ax.pie(sizes, wedge_labels='{frac:.1%}') + +# %% +# +# or both. fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, autopct='%1.1f%%') +ax.pie(sizes, wedge_labels='{absval:d}\n{frac:.1%}') + + +# %% +# +# Multiple labels +# --------------- +# +# Pass both a list of labels and a format string to *wedge_labels*, with a +# corresponding *wedge_label_distance* for each. + +fig, ax = plt.subplots() +ax.pie(sizes, wedge_labels=[labels, '{frac:.1%}'], wedge_label_distance=[1.1, 0.6]) # %% -# By default, the label values are obtained from the percent size of the slice. # # Color slices # ------------ @@ -48,7 +83,7 @@ # Pass a list of colors to *colors* to set the color of each slice. fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, +ax.pie(sizes, wedge_labels=labels, wedge_label_distance=1.1, colors=['olivedrab', 'rosybrown', 'gray', 'saddlebrown']) # %% @@ -58,22 +93,10 @@ # Pass a list of hatch patterns to *hatch* to set the pattern of each slice. fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, hatch=['**O', 'oO', 'O.O', '.||.']) - -# %% -# Swap label and autopct text positions -# ------------------------------------- -# Use the *labeldistance* and *pctdistance* parameters to position the *labels* -# and *autopct* text respectively. - -fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, autopct='%1.1f%%', - pctdistance=1.25, labeldistance=.6) +ax.pie(sizes, wedge_labels=labels, wedge_label_distance=1.1, + hatch=['**O', 'oO', 'O.O', '.||.']) # %% -# *labeldistance* and *pctdistance* are ratios of the radius; therefore they -# vary between ``0`` for the center of the pie and ``1`` for the edge of the -# pie, and can be set to greater than ``1`` to place text outside the pie. # # Explode, shade, and rotate slices # --------------------------------- @@ -89,8 +112,8 @@ explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') fig, ax = plt.subplots() -ax.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%', - shadow=True, startangle=90) +ax.pie(sizes, explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], shadow=True, startangle=90) plt.show() # %% @@ -107,7 +130,8 @@ fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, autopct='%.0f%%', +ax.pie(sizes, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], textprops={'size': 'small'}, radius=0.5) plt.show() @@ -119,7 +143,8 @@ # the `.Shadow` patch. This can be used to modify the default shadow. fig, ax = plt.subplots() -ax.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%', +ax.pie(sizes, explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], shadow={'ox': -0.04, 'edgecolor': 'none', 'shade': 0.9}, startangle=90) plt.show() diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 0b1b0e57dc6d..6fe29bcd4b5a 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1429,7 +1429,8 @@ def _add_data_doc(docstring, replace_names): return docstring.replace(' DATA_PARAMETER_PLACEHOLDER', data_doc) -def _preprocess_data(func=None, *, replace_names=None, label_namer=None): +def _preprocess_data(func=None, *, replace_names=None, replace_names_multi=None, + label_namer=None): """ A decorator to add a 'data' kwarg to a function. @@ -1454,6 +1455,11 @@ def func(ax, *args, **kwargs): ... replace_names : list of str or None, default: None The list of parameter names for which lookup into *data* should be attempted. If None, replacement is attempted for all arguments. + replace_names_multi : list of str or None, default: None + As for *replace_names*, but if a sequence is passed, a lookup into *data* + will be attempted for each element of the sequence. Currently only + supported for parameters named in the function signature (not those passed via + *args or **kwargs). label_namer : str, default: None If set e.g. to "namer" (which must be a kwarg in the function's signature -- not as ``**kwargs``), if the *namer* argument passed in is @@ -1471,7 +1477,8 @@ def func(foo, label=None): ... if func is None: # Return the actual decorator. return functools.partial( _preprocess_data, - replace_names=replace_names, label_namer=label_namer) + replace_names=replace_names, replace_names_multi=replace_names_multi, + label_namer=label_namer) sig = inspect.signature(func) varargs_name = None @@ -1523,6 +1530,9 @@ def inner(ax, *args, data=None, **kwargs): else: if replace_names is None or k in replace_names: bound.arguments[k] = _replacer(data, v) + if (replace_names_multi is not None and k in replace_names_multi and + not cbook.is_scalar_or_string(v)): + bound.arguments[k] = [_replacer(data, vi) for vi in v] new_args = bound.args new_kwargs = bound.kwargs diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index f0bc139bdc11..0f7c4de34982 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -1,3 +1,4 @@ +import collections.abc import functools import itertools import logging @@ -3512,13 +3513,14 @@ def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, self.add_container(stem_container) return stem_container - @_api.make_keyword_only("3.10", "explode") - @_preprocess_data(replace_names=["x", "explode", "labels", "colors"]) - def pie(self, x, explode=None, labels=None, colors=None, - autopct=None, pctdistance=0.6, shadow=False, labeldistance=1.1, - startangle=0, radius=1, counterclock=True, - wedgeprops=None, textprops=None, center=(0, 0), - frame=False, rotatelabels=False, *, normalize=True, hatch=None): + @_preprocess_data(replace_names=["x", "explode", "labels", "colors", + "wedge_labels"], + replace_names_multi=["wedge_labels"]) + def pie(self, x, *, explode=None, labels=None, colors=None, wedge_labels=None, + wedge_label_distance=0.6, rotate_wedge_labels=False, autopct=None, + pctdistance=0.6, shadow=False, labeldistance=False, startangle=0, radius=1, + counterclock=True, wedgeprops=None, textprops=None, center=(0, 0), + frame=False, rotatelabels=False, normalize=True, hatch=None): """ Plot a pie chart. @@ -3538,7 +3540,12 @@ def pie(self, x, explode=None, labels=None, colors=None, of the radius with which to offset each wedge. labels : list, default: None - A sequence of strings providing the labels for each wedge + A sequence of strings providing the legend labels for each wedge + + .. deprecated:: 3.12 + In future these labels will not appear on the wedges but only be + made available for the legend. To label the wedges directly, use + wedge_labels. colors : :mpltype:`color` or list of :mpltype:`color`, default: None A sequence of colors through which the pie chart will cycle. If @@ -3551,12 +3558,76 @@ def pie(self, x, explode=None, labels=None, colors=None, .. versionadded:: 3.7 + wedge_labels : (list of) str or list of str, optional + Labels attached to the wedges. The radial position of the labels is + defined by *wedge_label_distance*. You can have one or multiple + sets of labels. + + If a single string, it should be a format string with ``absval`` + and/or ``frac`` placeholders. For example, to label each wedge + with its x-value and the percentage in brackets:: + + wedge_labels="{absval:d} ({frac:.0%})" + + A single list of strings defines one label per wedge. The length + must match the length of *x*. Example:: + + wedge_labels=["wedge_1", "wedge_2", "wedge_3"] + + You can attach multiple labels per wedge by passing a sequence of + lists e.g. :: + + wedge_labels=[ + ["wedge_1_label_1", "wedge_2_label_1", "wedge_3_label_1"], + ["wedge_1_label_2", "wedge_2_label_2", "wedge_3_label_2"], + ] + + or a combination, e.g. :: + + wedge_labels=[ + ["wedge_1_label_1", "wedge_2_label_1", "wedge_3_label_1"], + "{frac:.1%}" + ] + + .. versionadded:: 3.12 + + wedge_label_distance : float or list of float, default: 0.6 + The radial position of the wedge labels, relative to the pie radius. + Values > 1 are outside the wedge and values < 1 are inside the wedge. + + If you use more than one set of *wedge_labels*, you have to specify the + distance for each set of labels. Example:: + + wedge_labels=[ + ["wedge_1_inner", "wedge_2_inner", "wedge_3_inner"], + ["wedge_1_outer", "wedge_2_outer", "wedge_3_outer"], + ], + wedge_label_distance=[0.6, 1.3] + + .. versionadded:: 3.12 + + rotate_wedge_labels : bool or list of bool + If True, Rotate each wedge_label to the angle of the corresponding slice. + If a list, must have one element for each set of wedge_labels. Example:: + + wedge_labels=[ + ["wedge_1_inner", "wedge_2_inner", "wedge_3_inner"], + ["wedge_1_outer", "wedge_2_outer", "wedge_3_outer"], + ], + rotate_wedge_labels=[False, True] + + .. versionadded:: 3.12 + autopct : None or str or callable, default: None If not *None*, *autopct* is a string or function used to label the wedges with their numeric value. The label will be placed inside the wedge. If *autopct* is a format string, the label will be ``fmt % pct``. If *autopct* is a function, then it will be called. + .. admonition:: Discouraged + + Consider using the *wedge_labels* parameter instead. + pctdistance : float, default: 0.6 The relative distance along the radius at which the text generated by *autopct* is drawn. To draw the text outside the pie, @@ -3569,6 +3640,11 @@ def pie(self, x, explode=None, labels=None, colors=None, If set to ``None``, labels are not drawn but are still stored for use in `.legend`. + .. deprecated:: 3.12 + From v3.14 *labeldistance* will default to ``None`` and will + later be removed altogether. Use *wedge_labels* and + *wedge_label_distance* instead. + shadow : bool or dict, default: False If bool, whether to draw a shadow beneath the pie. If dict, draw a shadow passing the properties in the dict to `.Shadow`. @@ -3613,17 +3689,23 @@ def pie(self, x, explode=None, labels=None, colors=None, data : indexable object, optional DATA_PARAMETER_PLACEHOLDER + Additionally, if multiple sets of *wedge_labels* are required, one or more + elements in the sequence may be a ``data`` key:: + + wedge_labels=[s, '{frac:.0%}'] + Returns ------- patches : list A sequence of `matplotlib.patches.Wedge` instances - texts : list - A list of the label `.Text` instances. + wedgetexts : list + A list of the *wedge_label* `.Text` instances, and the *label* + `.Text` instances if *labeldistance* is not ``None``. autotexts : list A list of `.Text` instances for the numeric labels. This will only - be returned if the parameter *autopct* is not *None*. + be returned if the parameter *autopct* is not ``None``. Notes ----- @@ -3633,9 +3715,7 @@ def pie(self, x, explode=None, labels=None, colors=None, The Axes aspect ratio can be controlled with `.Axes.set_aspect`. """ self.set_aspect('equal') - # The use of float32 is "historical", but can't be changed without - # regenerating the test baselines. - x = np.asarray(x, np.float32) + x = np.asarray(x) if x.ndim > 1: raise ValueError("x must be 1D") @@ -3650,18 +3730,19 @@ def pie(self, x, explode=None, labels=None, colors=None, if sx == 0: raise ValueError('All wedge sizes are zero') + def check_length(name, values): + if len(values) != len(x): + raise ValueError(f"'{name}' must be of length 'x', not {len(values)}") + if normalize: - x = x / sx + fracs = x / sx elif sx > 1: raise ValueError('Cannot plot an unnormalized pie with sum(x) > 1') - if labels is None: - labels = [''] * len(x) + else: + fracs = x if explode is None: explode = [0] * len(x) - if len(x) != len(labels): - raise ValueError(f"'labels' must be of length 'x', not {len(labels)}") - if len(x) != len(explode): - raise ValueError(f"'explode' must be of length 'x', not {len(explode)}") + check_length("explode", explode) if colors is None: get_next_color = self._get_patches_for_fill.get_next_color else: @@ -3684,18 +3765,152 @@ def get_next_color(): if textprops is None: textprops = {} - texts = [] slices = [] autotexts = [] - for frac, label, expl in zip(x, labels, explode): - x, y = center + # Define some functions for choosing label fontize and horizontal alignment + # based on distance and whether we are right of center (i.e. cartesian x > 0) + + def legacy(distance, is_right): + # Used to place `labels`. This function can be removed when the + # `labeldistance` deprecation expires. Always align so the labels + # do not overlap the pie + ha = 'left' if is_right else 'right' + return mpl.rcParams['xtick.labelsize'], ha + + def flexible(distance, is_right): + if distance >= 1: + # Align so the labels do not overlap the pie + ha = 'left' if is_right else 'right' + else: + ha = 'center' + + return None, ha + + def fixed(distance, is_right): + # Used to place the labels generated with autopct. Always centered + # for backwards compatibility + return None, 'center' + + # Build a (possibly empty) list of lists of wedge labels, with corresponding + # lists of distances, rotation choices and alignment functions + + def sanitize_formatted_string(s): + if mpl._val_or_rc(textprops.get("usetex"), "text.usetex"): + # escape % (i.e. \%) if it is not already escaped + return re.sub(r"([^\\])%", r"\1\\%", s) + + return s + + def fmt_str_to_list(wl): + """Apply wedge label format string to each pie fraction""" + return [sanitize_formatted_string(wl.format(absval=absval, frac=frac)) + for absval, frac in zip(x, fracs)] + + if wedge_labels is None: + processed_wedge_labels = [] + wedge_label_distance = [] + rotate_wedge_labels = [] + elif isinstance(wedge_labels, str): + # Format string. + processed_wedge_labels = [fmt_str_to_list(wedge_labels)] + elif not isinstance(wedge_labels, collections.abc.Sequence): + raise TypeError("wedge_labels must be a string or sequence") + else: + wl0 = wedge_labels[0] + if isinstance(wl0, str) and wl0.format(absval=1, frac=1) == wl0: + # Plain string. Assume the sequence is one label per wedge. + check_length("wedge_labels", wedge_labels) + processed_wedge_labels = [wedge_labels] + else: + processed_wedge_labels = [] + for wl in wedge_labels: + if isinstance(wl, str): + # Format string + processed_wedge_labels.append(fmt_str_to_list(wl)) + else: + # Ready made list + check_length("wedge_labels[i]", wl) + processed_wedge_labels.append(wl) + + if isinstance(wedge_label_distance, Number): + wedge_label_distance = [wedge_label_distance] + else: + # Copy so we won't append to user input + wedge_label_distance = wedge_label_distance[:] + + n_label_sets = len(processed_wedge_labels) + if n_label_sets != (nd := len(wedge_label_distance)): + raise ValueError(f"Found {n_label_sets} sets of wedge labels but " + f"{nd} wedge label distances.") + + if isinstance(rotate_wedge_labels, bool): + rotate_wedge_labels = [rotate_wedge_labels] + else: + # Copy so we won't append to user input + rotate_wedge_labels = rotate_wedge_labels[:] + + if len(rotate_wedge_labels) == 1: + rotate_wedge_labels = rotate_wedge_labels * n_label_sets + elif n_label_sets != (nr := len(rotate_wedge_labels)): + raise ValueError(f"Found {n_label_sets} sets of wedge labels but " + f"{nr} wedge label rotation choices.") + + prop_funcs = [flexible] * n_label_sets + + if labels is None: + labels = [None] * len(x) + else: + check_length("labels", labels) + + if labeldistance is False: + msg = ("From %(removal)s labeldistance will default to None, and later " + "will be removed completely. To preserve existing behavior for " + "now, pass labeldistance=1.1. Consider using wedge_labels " + "instead of labels." + ) + _api.warn_deprecated("3.12", message=msg) + labeldistance = 1.1 + + if labeldistance is not None: + processed_wedge_labels.append(labels) + wedge_label_distance.append(labeldistance) + prop_funcs.append(legacy) + rotate_wedge_labels.append(rotatelabels) + + # wedgetexts will contain the returned Text objects for wedge_labels, and also + # those for labels if labeldistance is not None. + wedgetexts = [[]] * len(processed_wedge_labels) + + if autopct is not None: + if isinstance(autopct, str): + processed_pct = [sanitize_formatted_string(autopct % (100. * frac)) + for frac in fracs] + elif callable(autopct): + processed_pct = [sanitize_formatted_string(autopct(100. * frac)) + for frac in fracs] + else: + raise TypeError('autopct must be callable or a format string') + + processed_wedge_labels.append(processed_pct) + wedge_label_distance.append(pctdistance) + prop_funcs.append(fixed) + rotate_wedge_labels.append(False) + + # Transpose so we can loop over wedges + processed_wedge_labels = np.transpose(processed_wedge_labels) + if not processed_wedge_labels.size: + processed_wedge_labels = processed_wedge_labels.reshape(len(x), 0) + + for frac, label, expl, wls in zip(fracs, labels, explode, + processed_wedge_labels): + x_pos, y_pos = center theta2 = (theta1 + frac) if counterclock else (theta1 - frac) thetam = 2 * np.pi * 0.5 * (theta1 + theta2) - x += expl * math.cos(thetam) - y += expl * math.sin(thetam) + x_pos += expl * math.cos(thetam) + y_pos += expl * math.sin(thetam) - w = mpatches.Wedge((x, y), radius, 360. * min(theta1, theta2), + w = mpatches.Wedge((x_pos, y_pos), radius, 360. * min(theta1, theta2), 360. * max(theta1, theta2), facecolor=get_next_color(), hatch=next(hatch_cycle), @@ -3713,44 +3928,32 @@ def get_next_color(): shadow_dict.update(shadow) self.add_patch(mpatches.Shadow(w, **shadow_dict)) - if labeldistance is not None: - xt = x + labeldistance * radius * math.cos(thetam) - yt = y + labeldistance * radius * math.sin(thetam) - label_alignment_h = 'left' if xt > 0 else 'right' - label_alignment_v = 'center' - label_rotation = 'horizontal' - if rotatelabels: - label_alignment_v = 'bottom' if yt > 0 else 'top' - label_rotation = (np.rad2deg(thetam) - + (0 if xt > 0 else 180)) - t = self.text(xt, yt, label, - clip_on=False, - horizontalalignment=label_alignment_h, - verticalalignment=label_alignment_v, - rotation=label_rotation, - size=mpl.rcParams['xtick.labelsize']) - t.set(**textprops) - texts.append(t) - - if autopct is not None: - xt = x + pctdistance * radius * math.cos(thetam) - yt = y + pctdistance * radius * math.sin(thetam) - if isinstance(autopct, str): - s = autopct % (100. * frac) - elif callable(autopct): - s = autopct(100. * frac) - else: - raise TypeError( - 'autopct must be callable or a format string') - if mpl._val_or_rc(textprops.get("usetex"), "text.usetex"): - # escape % (i.e. \%) if it is not already escaped - s = re.sub(r"([^\\])%", r"\1\\%", s) - t = self.text(xt, yt, s, - clip_on=False, - horizontalalignment='center', - verticalalignment='center') - t.set(**textprops) - autotexts.append(t) + if wls.size > 0: + # Add wedge labels + for i, (label_text, label_distance, prop_func, rotate) in enumerate( + zip(wls, wedge_label_distance, prop_funcs, + rotate_wedge_labels)): + xt = x_pos + label_distance * radius * math.cos(thetam) + yt = y_pos + label_distance * radius * math.sin(thetam) + fontsize, label_alignment_h = prop_func(label_distance, xt > 0) + if rotate: + label_alignment_v = 'bottom' if yt > 0 else 'top' + label_rotation = (np.rad2deg(thetam) + (0 if xt > 0 else 180)) + else: + label_alignment_v = 'center' + label_rotation = 'horizontal' + t = self.text(xt, yt, label_text, + clip_on=False, + horizontalalignment=label_alignment_h, + verticalalignment=label_alignment_v, + rotation=label_rotation, + size=fontsize) + t.set(**textprops) + if i == len(wedgetexts): + # autopct texts are returned separately + autotexts.append(t) + else: + wedgetexts[i].append(t) theta1 = theta2 @@ -3761,10 +3964,13 @@ def get_next_color(): xlim=(-1.25 + center[0], 1.25 + center[0]), ylim=(-1.25 + center[1], 1.25 + center[1])) + if len(wedgetexts) == 1: + wedgetexts = wedgetexts[0] + if autopct is None: - return slices, texts + return slices, wedgetexts else: - return slices, texts, autotexts + return slices, wedgetexts, autotexts @staticmethod def _errorevery_to_mask(x, errorevery): diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index 69d251aa21f7..eedc0b8a5560 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -309,6 +309,9 @@ class Axes(_AxesBase): explode: ArrayLike | None = ..., labels: Sequence[str] | None = ..., colors: ColorType | Sequence[ColorType] | None = ..., + wedge_labels: str | Sequence | None = ..., + wedge_label_distance: float | Sequence = ..., + rotate_wedge_labels: bool | Sequence = ..., autopct: str | Callable[[float], str] | None = ..., pctdistance: float = ..., shadow: bool = ..., diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 4531cbd3b261..3fbaf7f2ef88 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -3917,13 +3917,17 @@ def phase_spectrum( @_copy_docstring_and_deprecators(Axes.pie) def pie( x: ArrayLike, + *, explode: ArrayLike | None = None, labels: Sequence[str] | None = None, colors: ColorType | Sequence[ColorType] | None = None, + wedge_labels: str | Sequence | None = None, + wedge_label_distance: float | Sequence = 0.6, + rotate_wedge_labels: bool | Sequence = False, autopct: str | Callable[[float], str] | None = None, pctdistance: float = 0.6, shadow: bool = False, - labeldistance: float | None = 1.1, + labeldistance: float | None = False, startangle: float = 0, radius: float = 1, counterclock: bool = True, @@ -3932,7 +3936,6 @@ def pie( center: tuple[float, float] = (0, 0), frame: bool = False, rotatelabels: bool = False, - *, normalize: bool = True, hatch: str | Sequence[str] | None = None, data=None, @@ -3942,6 +3945,9 @@ def pie( explode=explode, labels=labels, colors=colors, + wedge_labels=wedge_labels, + wedge_label_distance=wedge_label_distance, + rotate_wedge_labels=rotate_wedge_labels, autopct=autopct, pctdistance=pctdistance, shadow=shadow, diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 0c445f86d9aa..b1e89856efed 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -6324,8 +6324,31 @@ def test_pie_default(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') fig1, ax1 = plt.subplots(figsize=(8, 6)) - ax1.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90) + ax1.pie(sizes, explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], colors=colors, shadow=True, startangle=90) + + +@image_comparison(['pie_default.png'], tol=0.01) +def test_pie_default_legacy(): + # Same as above, but uses old labels and autopct. + # The slices will be ordered and plotted counter-clockwise. + labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' + sizes = [15, 30, 45, 10] + colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] + explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') + fig1, ax1 = plt.subplots(figsize=(8, 6)) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + ax1.pie(sizes, explode=explode, labels=labels, colors=colors, + autopct='%1.1f%%', shadow=True, startangle=90) + + +def test_pie_wedge_labels_absval(): + sizes = [15, 30, 45, 10] + fig, ax = plt.subplots() + _, wedge_texts = ax.pie(sizes, wedge_labels='{absval:03d}') + + for text, size in zip(wedge_texts, sizes): + assert text.get_text() == '0' + str(size) @image_comparison(['pie_linewidth_0', 'pie_linewidth_0', 'pie_linewidth_0'], @@ -6337,27 +6360,29 @@ def test_pie_linewidth_0(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], colors=colors, shadow=True, startangle=90, wedgeprops={'linewidth': 0}) - # Set aspect ratio to be equal so that pie is drawn as a circle. plt.axis('equal') - # Reuse testcase from above for a labeled data test + # Reuse testcase from above for a labeled data test. Include legend labels + # to smoke test that they are correctly unpacked. data = {"l": labels, "s": sizes, "c": colors, "ex": explode} fig = plt.figure() ax = fig.gca() - ax.pie("s", explode="ex", labels="l", colors="c", - autopct='%1.1f%%', shadow=True, startangle=90, - wedgeprops={'linewidth': 0}, data=data) + ax.pie("s", explode="ex", wedge_labels=["l", "{frac:.1%}"], colors="c", + wedge_label_distance=[1.1, 0.6], shadow=True, startangle=90, + labels="l", labeldistance=None, wedgeprops={'linewidth': 0}, + data=data) ax.axis('equal') # And again to test the pyplot functions which should also be able to be # called with a data kwarg plt.figure() - plt.pie("s", explode="ex", labels="l", colors="c", - autopct='%1.1f%%', shadow=True, startangle=90, - wedgeprops={'linewidth': 0}, data=data) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + plt.pie("s", explode="ex", labels="l", colors="c", + autopct='%1.1f%%', shadow=True, startangle=90, + wedgeprops={'linewidth': 0}, data=data) plt.axis('equal') @@ -6369,8 +6394,8 @@ def test_pie_center_radius(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], colors=colors, shadow=True, startangle=90, wedgeprops={'linewidth': 0}, center=(1, 2), radius=1.5) plt.annotate("Center point", xy=(1, 2), xytext=(1, 1.3), @@ -6389,8 +6414,8 @@ def test_pie_linewidth_2(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], colors=colors, shadow=True, startangle=90, wedgeprops={'linewidth': 2}) # Set aspect ratio to be equal so that pie is drawn as a circle. plt.axis('equal') @@ -6404,9 +6429,9 @@ def test_pie_ccw_true(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, - counterclock=True) + plt.pie(sizes, explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], colors=colors, shadow=True, startangle=90, + counterclock=True) # Set aspect ratio to be equal so that pie is drawn as a circle. plt.axis('equal') @@ -6420,18 +6445,18 @@ def test_pie_frame_grid(): # only "explode" the 2nd slice (i.e. 'Hogs') explode = (0, 0.1, 0, 0) - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], colors=colors, shadow=True, startangle=90, wedgeprops={'linewidth': 0}, frame=True, center=(2, 2)) - plt.pie(sizes[::-1], explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes[::-1], explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], colors=colors, shadow=True, startangle=90, wedgeprops={'linewidth': 0}, frame=True, center=(5, 2)) - plt.pie(sizes, explode=explode[::-1], labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode[::-1], wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], colors=colors, shadow=True, startangle=90, wedgeprops={'linewidth': 0}, frame=True, center=(3, 5)) # Set aspect ratio to be equal so that pie is drawn as a circle. @@ -6446,9 +6471,26 @@ def test_pie_rotatelabels_true(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, - rotatelabels=True) + plt.pie(sizes, explode=explode, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], colors=colors, shadow=True, startangle=90, + rotate_wedge_labels=[True, False]) + # Set aspect ratio to be equal so that pie is drawn as a circle. + plt.axis('equal') + + +@image_comparison(['pie_rotatelabels_true.png'], style='mpl20', tol=0.009) +def test_pie_rotatelabels_true_legacy(): + # As above but using legacy labels and rotatelabels parameters + # The slices will be ordered and plotted counter-clockwise. + labels = 'Hogwarts', 'Frogs', 'Dogs', 'Logs' + sizes = [15, 30, 45, 10] + colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] + explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') + + with pytest.warns(mpl.MatplotlibDeprecationWarning): + plt.pie(sizes, explode=explode, labels=labels, colors=colors, + wedge_labels='{frac:.1%}', shadow=True, startangle=90, + rotatelabels=True) # Set aspect ratio to be equal so that pie is drawn as a circle. plt.axis('equal') @@ -6460,7 +6502,7 @@ def test_pie_nolabel_but_legend(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, labeldistance=None, + wedge_labels='{frac:.1%}', shadow=True, startangle=90, labeldistance=None, rotatelabels=True) plt.axis('equal') plt.ylim(-1.2, 1.2) @@ -6501,8 +6543,33 @@ def test_pie_textprops(): rotation_mode="anchor", size=12, color="red") - _, texts, autopct = plt.gca().pie(data, labels=labels, autopct='%.2f', - textprops=textprops) + fig, ax = plt.subplots() + + _, texts = ax.pie(data, wedge_labels=[labels, '{frac:.1%}'], + wedge_label_distance=[1.1, 0.6], textprops=textprops) + for labels in texts: + for tx in labels: + assert tx.get_ha() == textprops["horizontalalignment"] + assert tx.get_va() == textprops["verticalalignment"] + assert tx.get_rotation() == textprops["rotation"] + assert tx.get_rotation_mode() == textprops["rotation_mode"] + assert tx.get_size() == textprops["size"] + assert tx.get_color() == textprops["color"] + + +def test_pie_textprops_legacy(): + data = [23, 34, 45] + labels = ["Long name 1", "Long name 2", "Long name 3"] + + textprops = dict(horizontalalignment="center", + verticalalignment="top", + rotation=90, + rotation_mode="anchor", + size=12, color="red") + + with pytest.warns(mpl.MatplotlibDeprecationWarning): + _, texts, autopct = plt.gca().pie(data, labels=labels, autopct='%.2f', + textprops=textprops) for labels in [texts, autopct]: for tx in labels: assert tx.get_ha() == textprops["horizontalalignment"]