Skip to content

FIX/ENH: Introduce a monolithic legend handler for Line2D #20699

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions doc/api/next_api_changes/behavior/20699-AFV.rst
Original file line number Diff line number Diff line change
@@ -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()})

85 changes: 82 additions & 3 deletions lib/matplotlib/legend_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion lib/matplotlib/tests/test_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions lib/matplotlib/tests/test_legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()