diff --git a/doc/api/axes_api.rst b/doc/api/axes_api.rst index 84817c6d6bba..2f903d64f370 100644 --- a/doc/api/axes_api.rst +++ b/doc/api/axes_api.rst @@ -307,6 +307,8 @@ Axis Labels, title, and legend Axes.get_xlabel Axes.set_ylabel Axes.get_ylabel + Axes.set_xlabel_legend + Axes.set_ylabel_legend Axes.set_title Axes.get_title diff --git a/doc/users/next_whats_new/2019-12-22-axis-label-legend.rst b/doc/users/next_whats_new/2019-12-22-axis-label-legend.rst new file mode 100644 index 000000000000..d16c9f704ac6 --- /dev/null +++ b/doc/users/next_whats_new/2019-12-22-axis-label-legend.rst @@ -0,0 +1,7 @@ +Support for axis label legends +------------------------------ + +Legends can now be placed next to axis labels, enabling more +space-efficient and intuitive ``twinx()`` plots. Such legends are created +using ``plt.xlabel_legend()`` and ``plt.ylabel_legend()``, which each +accept one artist handle as understood by ``plt.legend()``. diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 702b6a0db813..78cdbb9c155b 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -214,6 +214,21 @@ def set_xlabel(self, xlabel, fontdict=None, labelpad=None, **kwargs): self.xaxis.labelpad = labelpad return self.xaxis.set_label_text(xlabel, fontdict, **kwargs) + def set_xlabel_legend(self, handle, **kwargs): + """ + Place a legend next to the axis label. + + Parameters + ---------- + handle: `.Artist` + An artist (e.g. lines, patches) to be shown as legend. + + **kwargs: `.LegendConfig` properties + Additional properties to control legend appearance. + """ + label = self.xaxis.get_label() + label.set_legend_handle(handle, **kwargs) + def get_ylabel(self): """ Get the ylabel text string. @@ -248,6 +263,36 @@ def set_ylabel(self, ylabel, fontdict=None, labelpad=None, **kwargs): self.yaxis.labelpad = labelpad return self.yaxis.set_label_text(ylabel, fontdict, **kwargs) + def set_ylabel_legend(self, handle, **kwargs): + """ + Place a legend next to the axis label. + + Parameters + ---------- + handle: `.Artist` + An artist (e.g. lines, patches) to be shown as legend. + + **kwargs: `.LegendConfig` properties + Additional properties to control legend appearance. + + Examples + -------- + Plot two lines on different axes with legends placed next to the + axis labels:: + + artist, = plt.plot([0, 1, 2], [0, 1, 2], "b-") + plt.ylabel('Density') + plt.ylabel_legend(artist) + + plt.twinx() + artist, = plt.plot([0, 1, 2], [0, 3, 2], "r-") + plt.ylabel('Temperature') + plt.ylabel_legend(artist) + plt.show() + """ + label = self.yaxis.get_label() + label.set_legend_handle(handle, **kwargs) + def get_legend_handles_labels(self, legend_handler_map=None): """ Return handles and labels for legend diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index a52bd54ab0d5..352e50aeb10f 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -11,6 +11,7 @@ import matplotlib.artist as martist import matplotlib.cbook as cbook import matplotlib.font_manager as font_manager +import matplotlib.legend as mlegend import matplotlib.lines as mlines import matplotlib.scale as mscale import matplotlib.text as mtext @@ -691,7 +692,7 @@ def __init__(self, axes, pickradius=15): self._autolabelpos = True self._smart_bounds = False # Deprecated in 3.2 - self.label = mtext.Text( + self.label = mlegend.TextWithLegend( np.nan, np.nan, fontproperties=font_manager.FontProperties( size=rcParams['axes.labelsize'], diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 8a6dc90f6d7f..e662ed052887 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -36,6 +36,7 @@ from matplotlib.collections import (LineCollection, RegularPolyCollection, CircleCollection, PathCollection, PolyCollection) +from matplotlib.text import Text from matplotlib.transforms import Bbox, BboxBase, TransformedBbox from matplotlib.transforms import BboxTransformTo, BboxTransformFrom @@ -175,24 +176,6 @@ def _update_bbox_to_anchor(self, loc_in_canvas): absolute font size in points. String values are relative to the current default font size. This argument is only used if *prop* is not specified. -numpoints : int, default: :rc:`legend.numpoints` - The number of marker points in the legend when creating a legend - entry for a `.Line2D` (line). - -scatterpoints : int, default: :rc:`legend.scatterpoints` - The number of marker points in the legend when creating - a legend entry for a `.PathCollection` (scatter plot). - -scatteryoffsets : iterable of floats, default: ``[0.375, 0.5, 0.3125]`` - The vertical offset (relative to the font size) for the markers - created for a scatter plot legend entry. 0.0 is at the base the - legend text, and 1.0 is at the top. To draw all markers at the - same height, set to ``[0.5]``. - -markerscale : float, default: :rc:`legend.markerscale` - The relative size of legend markers compared with the originally - drawn ones. - markerfirst : bool, default: True If *True*, legend marker is placed to the left of the legend label. If *False*, legend marker is placed to the right of the legend label. @@ -236,32 +219,309 @@ def _update_bbox_to_anchor(self, loc_in_canvas): title_fontsize: str or None The fontsize of the legend's title. Default is the default fontsize. -borderpad : float, default: :rc:`legend.borderpad` - The fractional whitespace inside the legend border, in font-size units. - labelspacing : float, default: :rc:`legend.labelspacing` The vertical space between the legend entries, in font-size units. -handlelength : float, default: :rc:`legend.handlelength` - The length of the legend handles, in font-size units. - -handletextpad : float, default: :rc:`legend.handletextpad` - The pad between the legend handle and text, in font-size units. - borderaxespad : float, default: :rc:`legend.borderaxespad` The pad between the axes and legend border, in font-size units. columnspacing : float, default: :rc:`legend.columnspacing` The spacing between columns, in font-size units. +""") + +docstring.interpd.update(_legend_config_kw_doc=""" +numpoints : int, default: :rc:`legend.numpoints` + The number of marker points in the legend when creating a legend + entry for a `.Line2D` (line). + +scatterpoints : int, default: :rc:`legend.scatterpoints` + The number of marker points in the legend when creating + a legend entry for a `.PathCollection` (scatter plot). + +scatteryoffsets : iterable of floats, default: ``[0.375, 0.5, 0.3125]`` + The vertical offset (relative to the font size) for the markers + created for a scatter plot legend entry. 0.0 is at the base the + legend text, and 1.0 is at the top. To draw all markers at the + same height, set to ``[0.5]``. + +markerscale : float, default: :rc:`legend.markerscale` + The relative size of legend markers compared with the originally + drawn ones. + +borderpad : float, default: :rc:`legend.borderpad` + The fractional whitespace inside the legend border, in font-size units. + +handlelength : float, default: :rc:`legend.handlelength` + The length of the legend handles, in font-size units. + +handletextpad : float, default: :rc:`legend.handletextpad` + The pad between the legend handle and text, in font-size units. handler_map : dict or None The custom dictionary mapping instances or types to a legend handler. This `handler_map` updates the default handler map - found at :func:`matplotlib.legend.Legend.get_legend_handler_map`. + found at :func:`matplotlib.legend.LegendConfig.get_legend_handler_map`. """) -class Legend(Artist): +class LegendConfig(object): + """ + Shared elements of regular legends and axis-label legends. + """ + @docstring.dedent_interpd + def __init__(self, + parent, + numpoints=None, # the number of points in the legend line + markerscale=None, # the relative size of legend markers + # vs. original + scatterpoints=None, # number of scatter points + scatteryoffsets=None, + + # spacing & pad defined as a fraction of the font-size + borderpad=None, # the whitespace inside the legend border + handlelength=None, # the length of the legend handles + handleheight=None, # the height of the legend handles + handletextpad=None, # the pad between the legend handle + # and text + handler_map=None, + ): + """ + Parameters + ---------- + %(_legend_config_kw_doc)s + """ + + self.parent = parent + #: A dictionary with the extra handler mappings for this Legend + #: instance. + self._custom_handler_map = handler_map + + locals_view = locals() + for name in ['numpoints', 'markerscale', 'scatterpoints', 'borderpad', + 'handleheight', 'handlelength', 'handletextpad']: + if locals_view[name] is None: + value = rcParams["legend." + name] + else: + value = locals_view[name] + setattr(self, name, value) + del locals_view + + if self.numpoints <= 0: + raise ValueError("numpoints must be > 0; it was %d" % numpoints) + + # introduce y-offset for handles of the scatter plot + if scatteryoffsets is None: + self._scatteryoffsets = np.array([3. / 8., 4. / 8., 2.5 / 8.]) + else: + self._scatteryoffsets = np.asarray(scatteryoffsets) + reps = self.scatterpoints // len(self._scatteryoffsets) + 1 + self._scatteryoffsets = np.tile(self._scatteryoffsets, + reps)[:self.scatterpoints] + + def _approx_box_height(self, fontsize, renderer=None): + """ + Return the approximate height and descent of the DrawingArea + holding the legend handle. + """ + if renderer is None: + size = fontsize + else: + size = renderer.points_to_pixels(fontsize) + + # The approximate height and descent of text. These values are + # only used for plotting the legend handle. + descent = 0.35 * size * (self.handleheight - 0.7) + # 0.35 and 0.7 are just heuristic numbers and may need to be improved. + height = size * self.handleheight - descent + # each handle needs to be drawn inside a box of (x, y, w, h) = + # (0, -descent, width, height). And their coordinates should + # be given in the display coordinates. + return descent, height + + # _default_handler_map defines the default mapping between plot + # elements and the legend handlers. + + _default_handler_map = { + StemContainer: legend_handler.HandlerStem(), + ErrorbarContainer: legend_handler.HandlerErrorbar(), + Line2D: legend_handler.HandlerLine2D(), + Patch: legend_handler.HandlerPatch(), + LineCollection: legend_handler.HandlerLineCollection(), + RegularPolyCollection: legend_handler.HandlerRegularPolyCollection(), + CircleCollection: legend_handler.HandlerCircleCollection(), + BarContainer: legend_handler.HandlerPatch( + update_func=legend_handler.update_from_first_child), + tuple: legend_handler.HandlerTuple(), + PathCollection: legend_handler.HandlerPathCollection(), + PolyCollection: legend_handler.HandlerPolyCollection() + } + + # (get|set|update)_default_handler_maps are public interfaces to + # modify the default handler map. + + @classmethod + def get_default_handler_map(cls): + """ + A class method that returns the default handler map. + """ + return cls._default_handler_map + + @classmethod + def set_default_handler_map(cls, handler_map): + """ + A class method to set the default handler map. + """ + cls._default_handler_map = handler_map + + @classmethod + def update_default_handler_map(cls, handler_map): + """ + A class method to update the default handler map. + """ + cls._default_handler_map.update(handler_map) + + def get_legend_handler_map(self): + """ + Return the handler map. + """ + + default_handler_map = self.get_default_handler_map() + + if self._custom_handler_map: + hm = default_handler_map.copy() + hm.update(self._custom_handler_map) + return hm + else: + return default_handler_map + + @staticmethod + def get_legend_handler(legend_handler_map, orig_handle): + """ + Return a legend handler from *legend_handler_map* that + corresponds to *orig_handler*. + + *legend_handler_map* should be a dictionary object (that is + returned by the get_legend_handler_map method). + + It first checks if the *orig_handle* itself is a key in the + *legend_handler_map* and return the associated value. + Otherwise, it checks for each of the classes in its + method-resolution-order. If no matching key is found, it + returns ``None``. + """ + try: + return legend_handler_map[orig_handle] + except (TypeError, KeyError): # TypeError if unhashable. + pass + for handle_type in type(orig_handle).mro(): + try: + return legend_handler_map[handle_type] + except KeyError: + pass + return None + + def _warn_unsupported_artist(self, handle): + cbook._warn_external( + "Legend does not support {!r} instances.\nA proxy artist " + "may be used instead.\nSee: " + "http://matplotlib.org/users/legend_guide.html" + "#creating-artists-specifically-for-adding-to-the-legend-" + "aka-proxy-artists".format(handle)) + + def _set_artist_props(self, a): + self.parent._set_artist_props(a) + + +class TextWithLegend(Text): + """ + Place a legend symbol next to a text. + """ + def __init__(self, *args, **kwargs): + """ + Valid keyword arguments are: + + %(Text)s + """ + Text.__init__(self, *args, **kwargs) + self.legend_config = None + self.legend = None + + def _get_layout_with_legend(self, renderer): + if self.legend is None: + bbox, info, descent = Text._get_layout(self, renderer, 0) + return bbox, info, descent, 0 + legend_extent = self.legend.get_extent(renderer) + legend_width, legend_height, _, _ = legend_extent + padding = self.legend_config.handletextpad * self.get_size() + rotation = self.get_rotation() + if rotation == 0: + line_indent = legend_width + padding + bbox, info, descent = Text._get_layout(self, renderer, line_indent) + line, (w, h, d), x, y = info[0] + legend_offset = ( + x - line_indent, + y + (h-d - legend_height) / 2 + ) + elif rotation == 90: + line_indent = legend_height + padding + bbox, info, descent = Text._get_layout(self, renderer, line_indent) + line, (w, h, d), x, y = info[0] + legend_offset = ( + x - (h-d + legend_width) / 2, + y - line_indent + ) + return bbox, info, descent, legend_offset + + def _get_layout(self, renderer, firstline_indent=0): + bbox, info, descent, _ = self._get_layout_with_legend(renderer) + return bbox, info, descent + + def draw(self, renderer): + Text.draw(self, renderer) + if self.legend is not None: + bbox, info, _, offset = self._get_layout_with_legend(renderer) + x, y = offset + trans = self.get_transform() + posx, posy = self.get_unitless_position() + posx, posy = trans.transform((posx, posy)) + self.legend.set_offset((x + posx, y + posy)) + self.legend.draw(renderer) + + def set_legend_handle(self, handle, **kwargs): + """Initialize DrawingArea and legend artist""" + rotation = self.get_rotation() + if rotation not in [0, 90]: + cbook._warn_external("Legend symbols are only supported " + "for non-rotated texts and texts rotated by 90°.") + return + rotate = rotation == 90 + config = LegendConfig(self.figure, **kwargs) + self.legend_config = config + fontsize = self.get_fontsize() + descent, height = config._approx_box_height(fontsize) + + legend_handler_map = config.get_legend_handler_map() + handler = config.get_legend_handler(legend_handler_map, handle) + if handler is None: + config._warn_unsupported_artist(handle) + self.legend_config = None + self.legend = None + return + if rotate: + box_width, box_height = height, config.handlelength * fontsize + xdescent, ydescent = descent, 0 + else: + box_width, box_height = config.handlelength * fontsize, height + xdescent, ydescent = 0, descent + + self.legend = DrawingArea(width=box_width, height=box_height, + xdescent=xdescent, ydescent=ydescent) + # Create the artist for the legend which represents the + # original artist/handle. + handler.legend_artist(config, handle, fontsize, self.legend, rotate) + + +class Legend(Artist, LegendConfig): """ Place a legend on the axes at location loc. @@ -287,24 +547,14 @@ def __str__(self): @docstring.dedent_interpd def __init__(self, parent, handles, labels, loc=None, - numpoints=None, # the number of points in the legend line - markerscale=None, # the relative size of legend markers - # vs. original markerfirst=True, # controls ordering (left-to-right) of # legend marker and label - scatterpoints=None, # number of scatter points - scatteryoffsets=None, prop=None, # properties for the legend texts fontsize=None, # keyword to set font size directly # spacing & pad defined as a fraction of the font-size - borderpad=None, # the whitespace inside the legend border labelspacing=None, # the vertical space between the legend # entries - handlelength=None, # the length of the legend handles - handleheight=None, # the height of the legend handles - handletextpad=None, # the pad between the legend handle - # and text borderaxespad=None, # the pad between the axes and legend # border columnspacing=None, # spacing between columns @@ -325,7 +575,7 @@ def __init__(self, parent, handles, labels, bbox_to_anchor=None, # bbox that the legend will be anchored. bbox_transform=None, # transform for the bbox frameon=None, # draw frame - handler_map=None, + **kwargs ): """ Parameters @@ -345,6 +595,8 @@ def __init__(self, parent, handles, labels, ---------------- %(_legend_kw_doc)s + %(_legend_config_kw_doc)s + Notes ----- Users can specify any arbitrary location for the legend using the @@ -361,6 +613,7 @@ def __init__(self, parent, handles, labels, from matplotlib.figure import Figure Artist.__init__(self) + LegendConfig.__init__(self, parent, **kwargs) if prop is None: if fontsize is not None: @@ -380,14 +633,8 @@ def __init__(self, parent, handles, labels, self.legendHandles = [] self._legend_title_box = None - #: A dictionary with the extra handler mappings for this Legend - #: instance. - self._custom_handler_map = handler_map - locals_view = locals() - for name in ["numpoints", "markerscale", "shadow", "columnspacing", - "scatterpoints", "handleheight", 'borderpad', - 'labelspacing', 'handlelength', 'handletextpad', + for name in ['shadow', 'columnspacing', 'labelspacing', 'borderaxespad']: if locals_view[name] is None: value = rcParams["legend." + name] @@ -412,18 +659,6 @@ def __init__(self, parent, handles, labels, ncol = 1 self._ncol = ncol - if self.numpoints <= 0: - raise ValueError("numpoints must be > 0; it was %d" % numpoints) - - # introduce y-offset for handles of the scatter plot - if scatteryoffsets is None: - self._scatteryoffsets = np.array([3. / 8., 4. / 8., 2.5 / 8.]) - else: - self._scatteryoffsets = np.asarray(scatteryoffsets) - reps = self.scatterpoints // len(self._scatteryoffsets) + 1 - self._scatteryoffsets = np.tile(self._scatteryoffsets, - reps)[:self.scatterpoints] - # _legend_box is an OffsetBox instance that contains all # legend items and will be initialized from _init_legend_box() # method. @@ -613,98 +848,6 @@ def draw(self, renderer): renderer.close_group('legend') self.stale = False - def _approx_text_height(self, renderer=None): - """ - Return the approximate height of the text. This is used to place - the legend handle. - """ - if renderer is None: - return self._fontsize - else: - return renderer.points_to_pixels(self._fontsize) - - # _default_handler_map defines the default mapping between plot - # elements and the legend handlers. - - _default_handler_map = { - StemContainer: legend_handler.HandlerStem(), - ErrorbarContainer: legend_handler.HandlerErrorbar(), - Line2D: legend_handler.HandlerLine2D(), - Patch: legend_handler.HandlerPatch(), - LineCollection: legend_handler.HandlerLineCollection(), - RegularPolyCollection: legend_handler.HandlerRegularPolyCollection(), - CircleCollection: legend_handler.HandlerCircleCollection(), - BarContainer: legend_handler.HandlerPatch( - update_func=legend_handler.update_from_first_child), - tuple: legend_handler.HandlerTuple(), - PathCollection: legend_handler.HandlerPathCollection(), - PolyCollection: legend_handler.HandlerPolyCollection() - } - - # (get|set|update)_default_handler_maps are public interfaces to - # modify the default handler map. - - @classmethod - def get_default_handler_map(cls): - """ - A class method that returns the default handler map. - """ - return cls._default_handler_map - - @classmethod - def set_default_handler_map(cls, handler_map): - """ - A class method to set the default handler map. - """ - cls._default_handler_map = handler_map - - @classmethod - def update_default_handler_map(cls, handler_map): - """ - A class method to update the default handler map. - """ - cls._default_handler_map.update(handler_map) - - def get_legend_handler_map(self): - """ - Return the handler map. - """ - - default_handler_map = self.get_default_handler_map() - - if self._custom_handler_map: - hm = default_handler_map.copy() - hm.update(self._custom_handler_map) - return hm - else: - return default_handler_map - - @staticmethod - def get_legend_handler(legend_handler_map, orig_handle): - """ - Return a legend handler from *legend_handler_map* that - corresponds to *orig_handler*. - - *legend_handler_map* should be a dictionary object (that is - returned by the get_legend_handler_map method). - - It first checks if the *orig_handle* itself is a key in the - *legend_handler_map* and return the associated value. - Otherwise, it checks for each of the classes in its - method-resolution-order. If no matching key is found, it - returns ``None``. - """ - try: - return legend_handler_map[orig_handle] - except (TypeError, KeyError): # TypeError if unhashable. - pass - for handle_type in type(orig_handle).mro(): - try: - return legend_handler_map[handle_type] - except KeyError: - pass - return None - def _init_legend_box(self, handles, labels, markerfirst=True): """ Initialize the legend_box. The legend_box is an instance of @@ -732,14 +875,7 @@ def _init_legend_box(self, handles, labels, markerfirst=True): fontproperties=self.prop, ) - # The approximate height and descent of text. These values are - # only used for plotting the legend handle. - descent = 0.35 * self._approx_text_height() * (self.handleheight - 0.7) - # 0.35 and 0.7 are just heuristic numbers and may need to be improved. - height = self._approx_text_height() * self.handleheight - descent - # each handle needs to be drawn inside a box of (x, y, w, h) = - # (0, -descent, width, height). And their coordinates should - # be given in the display coordinates. + descent, height = self._approx_box_height(self._fontsize) # The transformation of each handle will be automatically set # to self.get_transform(). If the artist does not use its @@ -750,12 +886,7 @@ def _init_legend_box(self, handles, labels, markerfirst=True): for orig_handle, lab in zip(handles, labels): handler = self.get_legend_handler(legend_handler_map, orig_handle) if handler is None: - cbook._warn_external( - "Legend does not support {!r} instances.\nA proxy artist " - "may be used instead.\nSee: " - "http://matplotlib.org/users/legend_guide.html" - "#creating-artists-specifically-for-adding-to-the-legend-" - "aka-proxy-artists".format(orig_handle)) + self._warn_unsupported_artist(orig_handle) # We don't have a handle for this artist, so we just defer # to None. handle_list.append(None) diff --git a/lib/matplotlib/legend_handler.py b/lib/matplotlib/legend_handler.py index 051c97da0d0b..a29412f3cab2 100644 --- a/lib/matplotlib/legend_handler.py +++ b/lib/matplotlib/legend_handler.py @@ -30,6 +30,7 @@ def legend_artist(self, legend, orig_handle, fontsize, handlebox) from matplotlib.lines import Line2D from matplotlib.patches import Rectangle +from matplotlib.transforms import Affine2D import matplotlib.collections as mcoll import matplotlib.colors as mcolors @@ -87,7 +88,7 @@ def adjust_drawing_area(self, legend, orig_handle, return xdescent, ydescent, width, height def legend_artist(self, legend, orig_handle, - fontsize, handlebox): + fontsize, handlebox, rotate=False): """ Return the artist that this HandlerBase generates for the given original artist/handle. @@ -112,9 +113,16 @@ def legend_artist(self, legend, orig_handle, handlebox.xdescent, handlebox.ydescent, handlebox.width, handlebox.height, fontsize) + transform = handlebox.get_transform() + if rotate: + point = (width - xdescent) / 2 + rotate_transform = Affine2D().rotate_deg_around(point, point, 90) + transform = rotate_transform + transform + width, height = height, width + xdescent, ydescent = ydescent, xdescent artists = self.create_artists(legend, orig_handle, xdescent, ydescent, width, height, - fontsize, handlebox.get_transform()) + fontsize, transform) # create_artists will return a list of artists. for a in artists: @@ -407,6 +415,12 @@ def create_artists(self, legend, orig_handle, self.update_prop(p, orig_handle, legend) p._transOffset = trans + if trans.is_affine: + # trans has only been applied to offset so far. + # Manually apply rotation and scaling to p + m = trans.get_matrix().copy() + m[:2, 2] = 0 + p.set_transform(Affine2D(m)) return [p] diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 508f11cbd286..3a1a4d04b4ad 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -2854,6 +2854,18 @@ def ylabel(ylabel, fontdict=None, labelpad=None, **kwargs): ylabel, fontdict=fontdict, labelpad=labelpad, **kwargs) +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@docstring.copy(Axes.set_xlabel_legend) +def xlabel_legend(handle, **kwargs): + return gca().set_xlabel_legend(handle, **kwargs) + + +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@docstring.copy(Axes.set_ylabel_legend) +def ylabel_legend(handle, **kwargs): + return gca().set_ylabel_legend(handle, **kwargs) + + # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @docstring.copy(Axes.set_xscale) def xscale(value, **kwargs): diff --git a/lib/matplotlib/tests/baseline_images/test_axes/label_legend.png b/lib/matplotlib/tests/baseline_images/test_axes/label_legend.png new file mode 100644 index 000000000000..0bf5921433d5 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/label_legend.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index d65906fdcf92..e13536f8bd76 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -45,6 +45,26 @@ def test_get_labels(): assert ax.get_ylabel() == 'y label' +@image_comparison(['label_legend.png'], style='mpl20') +def test_label_legends(): + fig, ax1 = plt.subplots() + + line1, _, _ = ax1.bar([0, 1, 2], [0, 1, 2], color='gray') + ax1.set_xlabel('X label 1') + ax1.set_ylabel('A very long Y label which requires\nmultiple lines') + ax1.set_ylabel_legend(line1) + + ax2 = ax1.twinx() + line2, = ax2.plot([0, 1, 2], [20, 10, 0], color='red') + ax2.set_ylabel('Y label 2') + ax2.set_ylabel_legend(line2) + + ax3 = ax2.twiny() + line3, = ax3.plot([100, 200, 300], [20, 5, 10], marker='D') + ax3.set_xlabel('X label 2') + ax3.set_xlabel_legend(line3) + + @image_comparison(['acorr.png'], style='mpl20') def test_acorr(): # Remove this line when this test image is regenerated. diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 3fd248bcc586..8795bf01e996 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -77,8 +77,8 @@ def _get_textbox(text, renderer): _, parts, d = text._get_layout(renderer) - for t, wh, x, y in parts: - w, h = wh + for t, whd, x, y in parts: + w, h, _ = whd xt1, yt1 = tr.transform((x, y)) yt1 -= d @@ -269,7 +269,7 @@ def update_from(self, other): self._linespacing = other._linespacing self.stale = True - def _get_layout(self, renderer): + def _get_layout(self, renderer, firstline_indent=0): """ return the extent (bbox) of the text together with multiple-alignment information. Note that it returns an extent @@ -284,6 +284,7 @@ def _get_layout(self, renderer): ws = [] hs = [] + ds = [] xs = [] ys = [] @@ -307,8 +308,8 @@ def _get_layout(self, renderer): h = max(h, lp_h) d = max(d, lp_d) - ws.append(w) hs.append(h) + ds.append(d) # Metrics of the last line that are needed later: baseline = (h - d) - thisy @@ -316,11 +317,16 @@ def _get_layout(self, renderer): if i == 0: # position at baseline thisy = -(h - d) + # reserve some space for the legend symbol + w += firstline_indent + thisx = firstline_indent else: # put baseline a good distance from bottom of previous line thisy -= max(min_dy, (h - d) * self._linespacing) + thisx = 0.0 - xs.append(thisx) # == 0. + ws.append(w) + xs.append(thisx) ys.append(thisy) thisy -= d @@ -422,7 +428,9 @@ def _get_layout(self, renderer): # now rotate the positions around the first (x, y) position xys = M.transform(offset_layout) - (offsetx, offsety) - ret = bbox, list(zip(lines, zip(ws, hs), *xys.T)), descent + ret = (bbox, + list(zip(lines, zip(ws, hs, ds), *xys.T)), + descent) self._cached[key] = ret return ret @@ -707,7 +715,7 @@ def draw(self, renderer): angle = textobj.get_rotation() - for line, wh, x, y in info: + for line, whd, x, y in info: mtext = textobj if len(info) == 1 else None x = x + posx diff --git a/tools/boilerplate.py b/tools/boilerplate.py index cafb850b44af..fc409f478f9c 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -260,6 +260,8 @@ def boilerplate_gen(): 'title:set_title', 'xlabel:set_xlabel', 'ylabel:set_ylabel', + 'xlabel_legend:set_xlabel_legend', + 'ylabel_legend:set_ylabel_legend', 'xscale:set_xscale', 'yscale:set_yscale', ) diff --git a/tutorials/intermediate/legend_guide.py b/tutorials/intermediate/legend_guide.py index ec1861cfe6cf..1f46ef7a5886 100644 --- a/tutorials/intermediate/legend_guide.py +++ b/tutorials/intermediate/legend_guide.py @@ -165,7 +165,7 @@ # appropriate :class:`~matplotlib.legend_handler.HandlerBase` subclass. # The choice of handler subclass is determined by the following rules: # -# 1. Update :func:`~matplotlib.legend.Legend.get_legend_handler_map` +# 1. Update :func:`~matplotlib.legend.LegendConfig.get_legend_handler_map` # with the value in the ``handler_map`` keyword. # 2. Check if the ``handle`` is in the newly created ``handler_map``. # 3. Check if the type of ``handle`` is in the newly created @@ -174,7 +174,7 @@ # created ``handler_map``. # # For completeness, this logic is mostly implemented in -# :func:`~matplotlib.legend.Legend.get_legend_handler`. +# :func:`~matplotlib.legend.LegendConfig.get_legend_handler`. # # All of this flexibility means that we have the necessary hooks to implement # custom handlers for our own type of legend key.
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: