diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index bf88dd2b68a3..3aa2eba48ff2 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -9,6 +9,7 @@ line segments). """ +import itertools import math from numbers import Number import warnings @@ -163,6 +164,9 @@ def __init__(self, # list of unbroadcast/scaled linewidths self._us_lw = [0] self._linewidths = [0] + + self._gapcolor = None # Currently only used by LineCollection. + # Flags set by _set_mappable_flags: are colors from mapping an array? self._face_is_mapped = None self._edge_is_mapped = None @@ -406,6 +410,17 @@ def draw(self, renderer): gc, paths[0], combined_transform.frozen(), mpath.Path(offsets), offset_trf, tuple(facecolors[0])) else: + if self._gapcolor is not None: + # First draw paths within the gaps. + ipaths, ilinestyles = self._get_inverse_paths_linestyles() + renderer.draw_path_collection( + gc, transform.frozen(), ipaths, + self.get_transforms(), offsets, offset_trf, + [mcolors.to_rgba("none")], self._gapcolor, + self._linewidths, ilinestyles, + self._antialiaseds, self._urls, + "screen") + renderer.draw_path_collection( gc, transform.frozen(), paths, self.get_transforms(), offsets, offset_trf, @@ -1459,6 +1474,12 @@ def _get_default_edgecolor(self): def _get_default_facecolor(self): return 'none' + def set_alpha(self, alpha): + # docstring inherited + super().set_alpha(alpha) + if self._gapcolor is not None: + self.set_gapcolor(self._original_gapcolor) + def set_color(self, c): """ Set the edgecolor(s) of the LineCollection. @@ -1479,6 +1500,53 @@ def get_color(self): get_colors = get_color # for compatibility with old versions + def set_gapcolor(self, gapcolor): + """ + Set a color to fill the gaps in the dashed line style. + + .. note:: + + Striped lines are created by drawing two interleaved dashed lines. + There can be overlaps between those two, which may result in + artifacts when using transparency. + + This functionality is experimental and may change. + + Parameters + ---------- + gapcolor : color or list of colors or None + The color with which to fill the gaps. If None, the gaps are + unfilled. + """ + self._original_gapcolor = gapcolor + self._set_gapcolor(gapcolor) + + def _set_gapcolor(self, gapcolor): + if gapcolor is not None: + gapcolor = mcolors.to_rgba_array(gapcolor, self._alpha) + self._gapcolor = gapcolor + self.stale = True + + def get_gapcolor(self): + return self._gapcolor + + def _get_inverse_paths_linestyles(self): + """ + Returns the path and pattern for the gaps in the non-solid lines. + + This path and pattern is the inverse of the path and pattern used to + construct the non-solid lines. For solid lines, we set the inverse path + to nans to prevent drawing an inverse line. + """ + path_patterns = [ + (mpath.Path(np.full((1, 2), np.nan)), ls) + if ls == (0, None) else + (path, mlines._get_inverse_dash_pattern(*ls)) + for (path, ls) in + zip(self._paths, itertools.cycle(self._linestyles))] + + return zip(*path_patterns) + class EventCollection(LineCollection): """ diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index ef92b975b21d..680be42513fa 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -60,6 +60,18 @@ def _get_dash_pattern(style): return offset, dashes +def _get_inverse_dash_pattern(offset, dashes): + """Return the inverse of the given dash pattern, for filling the gaps.""" + # Define the inverse pattern by moving the last gap to the start of the + # sequence. + gaps = dashes[-1:] + dashes[:-1] + # Set the offset so that this new first segment is skipped + # (see backend_bases.GraphicsContextBase.set_dashes for offset definition). + offset_gaps = offset + dashes[-1] + + return offset_gaps, gaps + + def _scale_dashes(offset, dashes, lw): if not mpl.rcParams['lines.scale_dashes']: return offset, dashes @@ -780,14 +792,8 @@ def draw(self, renderer): lc_rgba = mcolors.to_rgba(self._gapcolor, self._alpha) gc.set_foreground(lc_rgba, isRGBA=True) - # Define the inverse pattern by moving the last gap to the - # start of the sequence. - dashes = self._dash_pattern[1] - gaps = dashes[-1:] + dashes[:-1] - # Set the offset so that this new first segment is skipped - # (see backend_bases.GraphicsContextBase.set_dashes for - # offset definition). - offset_gaps = self._dash_pattern[0] + dashes[-1] + offset_gaps, gaps = _get_inverse_dash_pattern( + *self._dash_pattern) gc.set_dashes(offset_gaps, gaps) renderer.draw_path(gc, tpath, affine.frozen()) diff --git a/lib/matplotlib/tests/test_collections.py b/lib/matplotlib/tests/test_collections.py index 07e15897454e..115d1c859807 100644 --- a/lib/matplotlib/tests/test_collections.py +++ b/lib/matplotlib/tests/test_collections.py @@ -1,5 +1,6 @@ from datetime import datetime import io +import itertools import re from types import SimpleNamespace @@ -1191,3 +1192,27 @@ def test_check_offsets_dtype(): unmasked_offsets = np.column_stack([x, y]) scat.set_offsets(unmasked_offsets) assert isinstance(scat.get_offsets(), type(unmasked_offsets)) + + +@pytest.mark.parametrize('gapcolor', ['orange', ['r', 'k']]) +@check_figures_equal(extensions=['png']) +@mpl.rc_context({'lines.linewidth': 20}) +def test_striped_lines(fig_test, fig_ref, gapcolor): + ax_test = fig_test.add_subplot(111) + ax_ref = fig_ref.add_subplot(111) + + for ax in [ax_test, ax_ref]: + ax.set_xlim(0, 6) + ax.set_ylim(0, 1) + + x = range(1, 6) + linestyles = [':', '-', '--'] + + ax_test.vlines(x, 0, 1, linestyle=linestyles, gapcolor=gapcolor, alpha=0.5) + + if isinstance(gapcolor, str): + gapcolor = [gapcolor] + + for x, gcol, ls in zip(x, itertools.cycle(gapcolor), + itertools.cycle(linestyles)): + ax_ref.axvline(x, 0, 1, linestyle=ls, gapcolor=gcol, alpha=0.5)