diff --git a/doc/api/next_api_changes/behavior/20699-AFV.rst b/doc/api/next_api_changes/behavior/20699-AFV.rst new file mode 100644 index 000000000000..9852290baa96 --- /dev/null +++ b/doc/api/next_api_changes/behavior/20699-AFV.rst @@ -0,0 +1,31 @@ +Change of the (default) legend handler for Line2D instances +----------------------------------------------------------- + +The default legend handler for Line2D instances (`.HandlerLine2D`) now +consistently exposes all the attributes and methods related to the line +marker (:ghissue:`11358`). This makes easy to change the marker features +after instantiating a legend. + +.. code:: + + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + + ax.plot([1, 3, 2], marker="s", label="Line", color="pink", mec="red", ms=8) + leg = ax.legend() + + leg.legendHandles[0].set_color("lightgray") + leg.legendHandles[0].set_mec("black") # marker edge color + +The former legend handler for Line2D objects has been renamed +`.HandlerLine2DCompound`. To revert to the previous behavior, one can use + +.. code:: + + import matplotlib.legend as mlegend + from matplotlib.legend_handler import HandlerLine2DCompound + from matplotlib.lines import Line2D + + mlegend.Legend.update_default_handler_map({Line2D: HandlerLine2DCompound()}) + diff --git a/lib/matplotlib/legend_handler.py b/lib/matplotlib/legend_handler.py index cfcef1fbde05..b289e26cc1f5 100644 --- a/lib/matplotlib/legend_handler.py +++ b/lib/matplotlib/legend_handler.py @@ -27,11 +27,12 @@ def legend_artist(self, legend, orig_handle, fontsize, handlebox) """ +from collections.abc import Sequence from itertools import cycle import numpy as np -from matplotlib import cbook +from matplotlib import _api, cbook from matplotlib.lines import Line2D from matplotlib.patches import Rectangle import matplotlib.collections as mcoll @@ -119,6 +120,9 @@ def legend_artist(self, legend, orig_handle, xdescent, ydescent, width, height, fontsize, handlebox.get_transform()) + if isinstance(artists, _Line2DHandleList): + artists = [artists[0]] + # create_artists will return a list of artists. for a in artists: handlebox.add_artist(a) @@ -204,10 +208,12 @@ def get_ydata(self, legend, xdescent, ydescent, width, height, fontsize): return ydata -class HandlerLine2D(HandlerNpoints): +class HandlerLine2DCompound(HandlerNpoints): """ - Handler for `.Line2D` instances. + Original handler for `.Line2D` instances, that relies on combining + a line-only with a marker-only artist. May be deprecated in the future. """ + def __init__(self, marker_pad=0.3, numpoints=None, **kwargs): """ Parameters @@ -252,6 +258,77 @@ def create_artists(self, legend, orig_handle, return [legline, legline_marker] +class _Line2DHandleList(Sequence): + def __init__(self, legline): + self._legline = legline + + def __len__(self): + return 2 + + def __getitem__(self, index): + if index != 0: + # Make HandlerLine2D return [self._legline] directly after + # deprecation elapses. + _api.warn_deprecated( + "3.5", message="Access to the second element returned by " + "HandlerLine2D is deprecated since %(since)s; it will be " + "removed %(removal)s.") + return [self._legline, self._legline][index] + + +class HandlerLine2D(HandlerNpoints): + """ + Handler for `.Line2D` instances. + + See Also + -------- + HandlerLine2DCompound : An earlier handler implementation, which used one + artist for the line and another for the marker(s). + """ + + def __init__(self, marker_pad=0.3, numpoints=None, **kw): + """ + Parameters + ---------- + marker_pad : float + Padding between points in legend entry. + numpoints : int + Number of points to show in legend entry. + **kwargs + Keyword arguments forwarded to `.HandlerNpoints`. + """ + HandlerNpoints.__init__(self, marker_pad=marker_pad, + numpoints=numpoints, **kw) + + def create_artists(self, legend, orig_handle, + xdescent, ydescent, width, height, fontsize, + trans): + + xdata, xdata_marker = self.get_xdata(legend, xdescent, ydescent, + width, height, fontsize) + + markevery = None + if self.get_numpoints(legend) == 1: + # Special case: one wants a single marker in the center + # and a line that extends on both sides. One will use a + # 3 points line, but only mark the #1 (i.e. middle) point. + xdata = np.linspace(xdata[0], xdata[-1], 3) + markevery = [1] + + ydata = np.full_like(xdata, (height - ydescent) / 2) + legline = Line2D(xdata, ydata, markevery=markevery) + + self.update_prop(legline, orig_handle, legend) + + if legend.markerscale != 1: + newsz = legline.get_markersize() * legend.markerscale + legline.set_markersize(newsz) + + legline.set_transform(trans) + + return _Line2DHandleList(legline) + + class HandlerPatch(HandlerBase): """ Handler for `.Patch` instances. @@ -710,6 +787,8 @@ def create_artists(self, legend, orig_handle, _a_list = handler.create_artists( legend, handle1, next(xds_cycle), ydescent, width, height, fontsize, trans) + if isinstance(_a_list, _Line2DHandleList): + _a_list = [_a_list[0]] a_list.extend(_a_list) return a_list diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 9f0272fa1269..78b07349a04d 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -1358,8 +1358,12 @@ def test_markevery(): ax.legend() -@image_comparison(['markevery_line'], remove_text=True) +@image_comparison(['markevery_line'], remove_text=True, tol=0.005) def test_markevery_line(): + # TODO: a slight change in rendering between Inkscape versions may explain + # why one had to introduce a small non-zero tolerance for the SVG test + # to pass. One may try to remove this hack once Travis' Inkscape version + # is modern enough. FWIW, no failure with 0.92.3 on my computer (#11358). x = np.linspace(0, 10, 100) y = np.sin(x) * np.sqrt(x/10 + 0.5) diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 48172cbd9b24..c49ea996d056 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -10,6 +10,7 @@ import matplotlib as mpl import matplotlib.transforms as mtransforms import matplotlib.collections as mcollections +import matplotlib.lines as mlines from matplotlib.legend_handler import HandlerTuple import matplotlib.legend as mlegend from matplotlib import rc_context @@ -861,3 +862,12 @@ def test_legend_text_axes(): assert leg.axes is ax assert leg.get_texts()[0].axes is ax + + +def test_handlerline2d(): + # Test marker consistency for monolithic Line2D legend handler (#11357). + fig, ax = plt.subplots() + ax.scatter([0, 1], [0, 1], marker="v") + handles = [mlines.Line2D([0], [0], marker="v")] + leg = ax.legend(handles, ["Aardvark"], numpoints=1) + assert handles[0].get_marker() == leg.legendHandles[0].get_marker()