From 64b97b80910ef19cbbb7fa706d31aa04197ae958 Mon Sep 17 00:00:00 2001 From: Alexander Hartl Date: Sun, 22 Dec 2019 16:56:13 +0100 Subject: [PATCH 1/9] Added axis label legend functionality --- lib/matplotlib/axes/_axes.py | 45 ++++ lib/matplotlib/legend.py | 412 +++++++++++++++++++------------ lib/matplotlib/legend_handler.py | 18 +- lib/matplotlib/text.py | 66 ++++- 4 files changed, 369 insertions(+), 172 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 702b6a0db813..4b5a18222be4 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: `.BasicLegend` 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: `.BasicLegend` 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/legend.py b/lib/matplotlib/legend.py index 8a6dc90f6d7f..c70eb629766f 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -175,24 +175,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,23 +218,43 @@ 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(_basic_legend_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 @@ -261,7 +263,218 @@ def _update_bbox_to_anchor(self, loc_in_canvas): """) -class Legend(Artist): +class BasicLegend(object): + """ + Shared elements of regular legends and axis-label legends. + """ + @docstring.dedent_interpd + def __init__(self, + 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 + ---------- + %(_basic_legend_kw_doc)s + """ + + #: 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)) + + +class AxisLabelLegend(BasicLegend): + """ + Place a legend next to the axis labels. + """ + def __init__(self, text, **kwargs): + """ + Parameters + ---------- + text: `.Text` + The text object this legend belongs to. + + **kwargs: `.BasicLegend` properties + Additional properties controlling legend appearance. + + """ + BasicLegend.__init__(self, **kwargs) + self.text = text + self.figure = text.figure + + def init_legend(self, handle, rotate): + """Initialize DrawingArea and legend artist""" + fontsize = self.text.get_fontsize() + descent, height = self._approx_box_height(fontsize) + + legend_handler_map = self.get_legend_handler_map() + handler = self.get_legend_handler(legend_handler_map, handle) + if handler is None: + self._warn_unsupported_artist(handle) + return None + if rotate: + box_width, box_height = height, self.handlelength * fontsize + xdescent, ydescent = descent, 0 + else: + box_width, box_height = self.handlelength * fontsize, height + xdescent, ydescent = 0, descent + + self.box = 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(self, handle, fontsize, self.box, rotate) + return self + + def _set_artist_props(self, a): + a.set_figure(self.text.figure) + a.axes = self.text.axes + + +class Legend(Artist, BasicLegend): """ Place a legend on the axes at location loc. @@ -287,24 +500,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 +528,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 +548,8 @@ def __init__(self, parent, handles, labels, ---------------- %(_legend_kw_doc)s + %(_basic_legend_kw_doc)s + Notes ----- Users can specify any arbitrary location for the legend using the @@ -361,6 +566,7 @@ def __init__(self, parent, handles, labels, from matplotlib.figure import Figure Artist.__init__(self) + BasicLegend.__init__(self, **kwargs) if prop is None: if fontsize is not None: @@ -380,14 +586,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 +612,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 +801,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 +828,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 +839,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/text.py b/lib/matplotlib/text.py index 3fd248bcc586..266cc3599bff 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -75,7 +75,7 @@ def _get_textbox(text, renderer): theta = np.deg2rad(text.get_rotation()) tr = Affine2D().rotate(-theta) - _, parts, d = text._get_layout(renderer) + _, parts, d, _ = text._get_layout(renderer) for t, wh, x, y in parts: w, h = wh @@ -164,6 +164,7 @@ def __init__(self, if linespacing is None: linespacing = 1.2 # Maybe use rcParam later. self._linespacing = linespacing + self.legend = None self.set_rotation_mode(rotation_mode) self.update(kwargs) @@ -281,6 +282,7 @@ def _get_layout(self, renderer): thisx, thisy = 0.0, 0.0 lines = self.get_text().split("\n") # Ensures lines is not empty. + legend_offset = (0, 0) ws = [] hs = [] @@ -307,7 +309,6 @@ def _get_layout(self, renderer): h = max(h, lp_h) d = max(d, lp_d) - ws.append(w) hs.append(h) # Metrics of the last line that are needed later: @@ -316,11 +317,31 @@ def _get_layout(self, renderer): if i == 0: # position at baseline thisy = -(h - d) + # reserve some space for the legend symbol + if self.legend is not None: + legend_extent = self.legend.box.get_extent(renderer) + legend_width, legend_height, _, _ = legend_extent + padding = self.legend.handletextpad * self.get_size() + rotation = self.get_rotation() + if rotation == 0: + legend_spacing = legend_width + padding + w += legend_spacing + thisx += legend_spacing + # position relative to the beginning of first line + legend_offset = (-legend_spacing, 0) + elif rotation == 90: + legend_spacing = legend_height + padding + w += legend_spacing + thisx += legend_spacing + # position relative to the beginning of first line + legend_offset = (-legend_width, -legend_spacing) else: # put baseline a good distance from bottom of previous line thisy -= max(min_dy, (h - d) * self._linespacing) + thisx = 0 - xs.append(thisx) # == 0. + ws.append(w) + xs.append(thisx) ys.append(thisy) thisy -= d @@ -422,7 +443,10 @@ 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), *xys.T)), + descent, + xys[0, :] + legend_offset) self._cached[key] = ret return ret @@ -682,7 +706,7 @@ def draw(self, renderer): renderer.open_group('text', self.get_gid()) with _wrap_text(self) as textobj: - bbox, info, descent = textobj._get_layout(renderer) + bbox, info, descent, legend_pos = textobj._get_layout(renderer) trans = textobj.get_transform() # don't use textobj.get_position here, which refers to text @@ -731,11 +755,41 @@ def draw(self, renderer): textrenderer.draw_text(gc, x, y, clean_line, textobj._fontproperties, angle, ismath=ismath, mtext=mtext) + if self.legend is not None and angle in [0, 90]: + x, y = legend_pos + self.legend.box.set_offset((x + posx, y + posy)) + self.legend.box.draw(renderer) gc.restore() renderer.close_group('text') self.stale = False + def set_legend_handle(self, handle=None, **kwargs): + """ + Set a legend to be shown next to the text. + + Parameters + ---------- + handle: `.Artist` + An artist (e.g. lines, patches) to be shown as legend. + + **kwargs: `.BasicLegend` properties + Additional properties to control legend appearance. + """ + # import AxisLabelLegend here to avoid circular import + from matplotlib.legend import AxisLabelLegend + if handle is not None: + 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 + legend = AxisLabelLegend(self, **kwargs) + self.legend = legend.init_legend(handle, rotation == 90) + else: + self.legend = None + self.stale = True + def get_color(self): "Return the color of the text" return self._color @@ -902,7 +956,7 @@ def get_window_extent(self, renderer=None, dpi=None): if self._renderer is None: raise RuntimeError('Cannot get window extent w/o renderer') - bbox, info, descent = self._get_layout(self._renderer) + bbox, info, descent, _ = self._get_layout(self._renderer) x, y = self.get_unitless_position() x, y = self.get_transform().transform((x, y)) bbox = bbox.translated(x, y) From a81346aed36bb89c06772bdb1595e70e4c434061 Mon Sep 17 00:00:00 2001 From: Alexander Hartl Date: Sun, 22 Dec 2019 16:56:53 +0100 Subject: [PATCH 2/9] Updated boilerplate.py --- lib/matplotlib/pyplot.py | 12 ++++++++++++ tools/boilerplate.py | 2 ++ 2 files changed, 14 insertions(+) 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/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', ) From a507f5d457093a0c9d08818649c638723dec7d3a Mon Sep 17 00:00:00 2001 From: Alexander Hartl Date: Sun, 22 Dec 2019 16:58:31 +0100 Subject: [PATCH 3/9] Added 'What's new' file --- doc/users/next_whats_new/2019-12-22-axis-label-legend.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 doc/users/next_whats_new/2019-12-22-axis-label-legend.rst 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()``. From 6e4b669766c7a447af16d9c0647569113b26b3e6 Mon Sep 17 00:00:00 2001 From: Alexander Hartl Date: Sun, 22 Dec 2019 17:30:09 +0100 Subject: [PATCH 4/9] Optimized legend alignment --- lib/matplotlib/text.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 266cc3599bff..6aaa3600b9e3 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -328,13 +328,19 @@ def _get_layout(self, renderer): w += legend_spacing thisx += legend_spacing # position relative to the beginning of first line - legend_offset = (-legend_spacing, 0) + legend_offset = ( + -legend_spacing, + (h-d - legend_height) / 2 + ) elif rotation == 90: legend_spacing = legend_height + padding w += legend_spacing thisx += legend_spacing # position relative to the beginning of first line - legend_offset = (-legend_width, -legend_spacing) + legend_offset = ( + -(h-d + legend_width) / 2, + -legend_spacing + ) else: # put baseline a good distance from bottom of previous line thisy -= max(min_dy, (h - d) * self._linespacing) From 9b63123f42bcd8919f812ab90640922e2ba05dfc Mon Sep 17 00:00:00 2001 From: Alexander Hartl Date: Sun, 22 Dec 2019 17:37:46 +0100 Subject: [PATCH 5/9] Fixed failing test --- lib/matplotlib/offsetbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/offsetbox.py b/lib/matplotlib/offsetbox.py index 9f9440abbc05..d7b7574ce30b 100644 --- a/lib/matplotlib/offsetbox.py +++ b/lib/matplotlib/offsetbox.py @@ -890,7 +890,7 @@ def get_extent(self, renderer): _, h_, d_ = renderer.get_text_width_height_descent( "lp", self._text._fontproperties, ismath=False) - bbox, info, d = self._text._get_layout(renderer) + bbox, info, d, _ = self._text._get_layout(renderer) w, h = bbox.width, bbox.height self._baseline_transform.clear() From a804d83d9a46f4149f5533031c98612cad668ec2 Mon Sep 17 00:00:00 2001 From: Alexander Hartl Date: Sun, 22 Dec 2019 17:38:37 +0100 Subject: [PATCH 6/9] Added label legend test --- .../test_axes/label_legend.png | Bin 0 -> 45723 bytes lib/matplotlib/tests/test_axes.py | 20 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 lib/matplotlib/tests/baseline_images/test_axes/label_legend.png 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 0000000000000000000000000000000000000000..0bf5921433d536182a472a6f5e843e0cb38b2d73 GIT binary patch literal 45723 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 literal 0 HcmV?d00001 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. From 0beaabd404ca5bb5af73db52c03755e57c231ea6 Mon Sep 17 00:00:00 2001 From: Alexander Hartl Date: Sun, 22 Dec 2019 21:50:34 +0100 Subject: [PATCH 7/9] Some docstring fixes --- doc/api/axes_api.rst | 2 ++ lib/matplotlib/legend.py | 2 +- tutorials/intermediate/legend_guide.py | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) 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/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index c70eb629766f..f20480ee2cc0 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -259,7 +259,7 @@ def _update_bbox_to_anchor(self, loc_in_canvas): 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.BasicLegend.get_legend_handler_map`. """) diff --git a/tutorials/intermediate/legend_guide.py b/tutorials/intermediate/legend_guide.py index ec1861cfe6cf..b6e44f7f6a5a 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.BasicLegend.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.BasicLegend.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. From 70eb85d1eb090f4d61839470ff840ca188316852 Mon Sep 17 00:00:00 2001 From: Alexander Hartl Date: Thu, 23 Jan 2020 15:14:54 +0100 Subject: [PATCH 8/9] Moved functionality to subclass of Text --- lib/matplotlib/axes/_axes.py | 4 +- lib/matplotlib/axis.py | 3 +- lib/matplotlib/legend.py | 122 ++++++++++++++++++++++++----------- lib/matplotlib/offsetbox.py | 2 +- lib/matplotlib/text.py | 80 ++++------------------- 5 files changed, 103 insertions(+), 108 deletions(-) diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 4b5a18222be4..78cdbb9c155b 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -223,7 +223,7 @@ def set_xlabel_legend(self, handle, **kwargs): handle: `.Artist` An artist (e.g. lines, patches) to be shown as legend. - **kwargs: `.BasicLegend` properties + **kwargs: `.LegendConfig` properties Additional properties to control legend appearance. """ label = self.xaxis.get_label() @@ -272,7 +272,7 @@ def set_ylabel_legend(self, handle, **kwargs): handle: `.Artist` An artist (e.g. lines, patches) to be shown as legend. - **kwargs: `.BasicLegend` properties + **kwargs: `.LegendConfig` properties Additional properties to control legend appearance. Examples 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 f20480ee2cc0..0285e6d0dc3d 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 @@ -228,7 +229,7 @@ def _update_bbox_to_anchor(self, loc_in_canvas): The spacing between columns, in font-size units. """) -docstring.interpd.update(_basic_legend_kw_doc=""" +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). @@ -259,16 +260,17 @@ def _update_bbox_to_anchor(self, loc_in_canvas): 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.BasicLegend.get_legend_handler_map`. + found at :func:`matplotlib.legend.LegendConfig.get_legend_handler_map`. """) -class BasicLegend(object): +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 @@ -286,9 +288,10 @@ def __init__(self, """ Parameters ---------- - %(_basic_legend_kw_doc)s + %(_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 @@ -425,56 +428,99 @@ def _warn_unsupported_artist(self, handle): "#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 AxisLabelLegend(BasicLegend): +class TextWithLegend(Text): """ - Place a legend next to the axis labels. + Place a legend symbol next to a text. """ - def __init__(self, text, **kwargs): + def __init__(self, *args, **kwargs): """ - Parameters - ---------- - text: `.Text` - The text object this legend belongs to. - - **kwargs: `.BasicLegend` properties - Additional properties controlling legend appearance. + Valid keyword arguments are: + %(Text)s """ - BasicLegend.__init__(self, **kwargs) - self.text = text - self.figure = text.figure + Text.__init__(self, *args, **kwargs) + self.legend_config = None + self.legend = None + + def _get_layout_with_legend(self, renderer): + if self.legend is None: + return *Text._get_layout(self, renderer, 0), 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 init_legend(self, handle, rotate): - """Initialize DrawingArea and legend artist""" - fontsize = self.text.get_fontsize() - descent, height = self._approx_box_height(fontsize) + def _get_layout(self, renderer, firstline_indent=0): + bbox, info, descent, _ = self._get_layout_with_legend(renderer) + return bbox, info, descent - legend_handler_map = self.get_legend_handler_map() - handler = self.get_legend_handler(legend_handler_map, handle) + 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: - self._warn_unsupported_artist(handle) - return None + config._warn_unsupported_artist(handle) + self.legend_config = None + self.legend = None + return if rotate: - box_width, box_height = height, self.handlelength * fontsize + box_width, box_height = height, config.handlelength * fontsize xdescent, ydescent = descent, 0 else: - box_width, box_height = self.handlelength * fontsize, height + box_width, box_height = config.handlelength * fontsize, height xdescent, ydescent = 0, descent - self.box = DrawingArea(width=box_width, height=box_height, - xdescent=xdescent, ydescent=ydescent) + 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(self, handle, fontsize, self.box, rotate) - return self - - def _set_artist_props(self, a): - a.set_figure(self.text.figure) - a.axes = self.text.axes + handler.legend_artist(config, handle, fontsize, self.legend, rotate) -class Legend(Artist, BasicLegend): +class Legend(Artist, LegendConfig): """ Place a legend on the axes at location loc. @@ -548,7 +594,7 @@ def __init__(self, parent, handles, labels, ---------------- %(_legend_kw_doc)s - %(_basic_legend_kw_doc)s + %(_legend_config_kw_doc)s Notes ----- @@ -566,7 +612,7 @@ def __init__(self, parent, handles, labels, from matplotlib.figure import Figure Artist.__init__(self) - BasicLegend.__init__(self, **kwargs) + LegendConfig.__init__(self, parent, **kwargs) if prop is None: if fontsize is not None: diff --git a/lib/matplotlib/offsetbox.py b/lib/matplotlib/offsetbox.py index d7b7574ce30b..9f9440abbc05 100644 --- a/lib/matplotlib/offsetbox.py +++ b/lib/matplotlib/offsetbox.py @@ -890,7 +890,7 @@ def get_extent(self, renderer): _, h_, d_ = renderer.get_text_width_height_descent( "lp", self._text._fontproperties, ismath=False) - bbox, info, d, _ = self._text._get_layout(renderer) + bbox, info, d = self._text._get_layout(renderer) w, h = bbox.width, bbox.height self._baseline_transform.clear() diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 6aaa3600b9e3..8795bf01e996 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -75,10 +75,10 @@ def _get_textbox(text, renderer): theta = np.deg2rad(text.get_rotation()) tr = Affine2D().rotate(-theta) - _, parts, d, _ = text._get_layout(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 @@ -164,7 +164,6 @@ def __init__(self, if linespacing is None: linespacing = 1.2 # Maybe use rcParam later. self._linespacing = linespacing - self.legend = None self.set_rotation_mode(rotation_mode) self.update(kwargs) @@ -270,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 @@ -282,10 +281,10 @@ def _get_layout(self, renderer): thisx, thisy = 0.0, 0.0 lines = self.get_text().split("\n") # Ensures lines is not empty. - legend_offset = (0, 0) ws = [] hs = [] + ds = [] xs = [] ys = [] @@ -310,6 +309,7 @@ def _get_layout(self, renderer): d = max(d, lp_d) hs.append(h) + ds.append(d) # Metrics of the last line that are needed later: baseline = (h - d) - thisy @@ -318,33 +318,12 @@ def _get_layout(self, renderer): # position at baseline thisy = -(h - d) # reserve some space for the legend symbol - if self.legend is not None: - legend_extent = self.legend.box.get_extent(renderer) - legend_width, legend_height, _, _ = legend_extent - padding = self.legend.handletextpad * self.get_size() - rotation = self.get_rotation() - if rotation == 0: - legend_spacing = legend_width + padding - w += legend_spacing - thisx += legend_spacing - # position relative to the beginning of first line - legend_offset = ( - -legend_spacing, - (h-d - legend_height) / 2 - ) - elif rotation == 90: - legend_spacing = legend_height + padding - w += legend_spacing - thisx += legend_spacing - # position relative to the beginning of first line - legend_offset = ( - -(h-d + legend_width) / 2, - -legend_spacing - ) + 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 + thisx = 0.0 ws.append(w) xs.append(thisx) @@ -450,9 +429,8 @@ def _get_layout(self, renderer): xys = M.transform(offset_layout) - (offsetx, offsety) ret = (bbox, - list(zip(lines, zip(ws, hs), *xys.T)), - descent, - xys[0, :] + legend_offset) + list(zip(lines, zip(ws, hs, ds), *xys.T)), + descent) self._cached[key] = ret return ret @@ -712,7 +690,7 @@ def draw(self, renderer): renderer.open_group('text', self.get_gid()) with _wrap_text(self) as textobj: - bbox, info, descent, legend_pos = textobj._get_layout(renderer) + bbox, info, descent = textobj._get_layout(renderer) trans = textobj.get_transform() # don't use textobj.get_position here, which refers to text @@ -737,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 @@ -761,41 +739,11 @@ def draw(self, renderer): textrenderer.draw_text(gc, x, y, clean_line, textobj._fontproperties, angle, ismath=ismath, mtext=mtext) - if self.legend is not None and angle in [0, 90]: - x, y = legend_pos - self.legend.box.set_offset((x + posx, y + posy)) - self.legend.box.draw(renderer) gc.restore() renderer.close_group('text') self.stale = False - def set_legend_handle(self, handle=None, **kwargs): - """ - Set a legend to be shown next to the text. - - Parameters - ---------- - handle: `.Artist` - An artist (e.g. lines, patches) to be shown as legend. - - **kwargs: `.BasicLegend` properties - Additional properties to control legend appearance. - """ - # import AxisLabelLegend here to avoid circular import - from matplotlib.legend import AxisLabelLegend - if handle is not None: - 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 - legend = AxisLabelLegend(self, **kwargs) - self.legend = legend.init_legend(handle, rotation == 90) - else: - self.legend = None - self.stale = True - def get_color(self): "Return the color of the text" return self._color @@ -962,7 +910,7 @@ def get_window_extent(self, renderer=None, dpi=None): if self._renderer is None: raise RuntimeError('Cannot get window extent w/o renderer') - bbox, info, descent, _ = self._get_layout(self._renderer) + bbox, info, descent = self._get_layout(self._renderer) x, y = self.get_unitless_position() x, y = self.get_transform().transform((x, y)) bbox = bbox.translated(x, y) From dded358b89e04b519f741cd9f59378f97df71944 Mon Sep 17 00:00:00 2001 From: Alexander Hartl Date: Thu, 23 Jan 2020 15:23:29 +0100 Subject: [PATCH 9/9] Fixed failing test --- lib/matplotlib/legend.py | 3 ++- tutorials/intermediate/legend_guide.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 0285e6d0dc3d..e662ed052887 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -448,7 +448,8 @@ def __init__(self, *args, **kwargs): def _get_layout_with_legend(self, renderer): if self.legend is None: - return *Text._get_layout(self, renderer, 0), 0 + 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() diff --git a/tutorials/intermediate/legend_guide.py b/tutorials/intermediate/legend_guide.py index b6e44f7f6a5a..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.BasicLegend.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.BasicLegend.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.