diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index cd0660470c2c..0fdaa1e58ab4 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -5219,9 +5219,8 @@ def fill(self, *args, data=None, **kwargs): self._request_autoscale_view() return patches - def _fill_between_x_or_y( - self, ind_dir, ind, dep1, dep2=0, *, - where=None, interpolate=False, step=None, **kwargs): + def _fill_between_x_or_y(self, ind_dir, ind, dep1, dep2=0, *, where=None, + interpolate=False, step=None, **kwargs): # Common implementation between fill_between (*ind_dir*="x") and # fill_betweenx (*ind_dir*="y"). *ind* is the independent variable, # *dep* the dependent variable. The docstring below is interpolated @@ -5307,7 +5306,6 @@ def _fill_between_x_or_y( fill_between : Fill between two sets of y-values. fill_betweenx : Fill between two sets of x-values. """ - dep_dir = {"x": "y", "y": "x"}[ind_dir] if not mpl.rcParams["_internal.classic_mode"]: @@ -5440,6 +5438,198 @@ def fill_betweenx(self, y, x1, x2=0, where=None, #### plotting z(x, y): imshow, pcolor and relatives, contour + def _fill_above_or_below(self, ind, dep1, dep2=0, *, where=None, + interpolate=False, step=None, **kwargs): + ind_dir = "y" + dep_dir = "x" + + if not mpl.rcParams["_internal.classic_mode"]: + kwargs = cbook.normalize_kwargs(kwargs, mcoll.Collection) + if not any(c in kwargs for c in ("color", "facecolor")): + kwargs["facecolor"] = \ + self._get_patches_for_fill.get_next_color() + + ind, dep1, dep2 = map( + ma.masked_invalid, self._process_unit_info( + [(ind_dir, ind), (dep_dir, dep1), (dep_dir, dep2)], kwargs)) + + for name, array in [ + (ind_dir, ind), (f"{dep_dir}1", dep1), (f"{dep_dir}2", dep2)]: + if array.ndim > 1: + raise ValueError(f"{name!r} is not 1-dimensional") + + if where is None: + where = True + else: + where = np.asarray(where, dtype=bool) + if where.size != ind.size: + raise ValueError(f"where size ({where.size}) does not match " + f"{ind_dir} size ({ind.size})") + where = where & ~functools.reduce( + np.logical_or, map(np.ma.getmaskarray, [ind, dep1, dep2])) + + ind, dep1, dep2 = np.broadcast_arrays( + np.atleast_1d(ind), dep1, dep2, subok=True) + + if interpolate: + N = len(ind) + xslices = np.stack([ind, dep1, dep2], axis=-1) + sorted_idx = np.argsort(ind, kind="stable") + xslices = xslices[sorted_idx] + ind_interps = [] + dep_interps = [] + where_interps = [] + for i in range(N-1): + # sliding window + # [[dep1[i], dep2[i+1]], + # [dep2[i], dep2[i+1]], + # [ind[i], ind[i+1]]] + cur_slice = xslices[i] + next_slice = xslices[i+1] + d1 = cur_slice[1] - cur_slice[2] + d2 = next_slice[1] - next_slice[2] + dx = next_slice[0] - cur_slice[0] + if dx == 0: + # multiple ind with same value + pass + if (d1 > 0) == (d2 > 0): + continue + if (d1 == 0) or (d2 == 0): + continue + # calculate intersect pt + r = -d2/d1 + ind_interps.append((next_slice[0] + r*cur_slice[0])/(1+r)) + dep_interps.append((next_slice[1] + r*cur_slice[1])/(1+r)) + where_interps.append(where[i] or where[i+1]) + + ind = np.concatenate([ind, ind_interps]) + dep1 = np.concatenate([dep1, dep_interps]) + dep2 = np.concatenate([dep2, dep_interps]) + where = np.concatenate([where, where_interps]) + + sorted_idx = np.argsort(ind, kind="stable") + ind = ind[sorted_idx] + dep1 = dep1[sorted_idx] + dep2 = dep2[sorted_idx] + where = where[sorted_idx] + + ret = [] + for idx0, idx1 in cbook.contiguous_regions(where): + indslice = ind[idx0:idx1] + dep1slice = dep1[idx0:idx1] + dep2slice = dep2[idx0:idx1] + + if step is not None: + step_func = cbook.STEP_LOOKUP_MAP["steps-" + step] + indslice, dep1slice, dep2slice = \ + step_func(indslice, dep1slice, dep2slice) + + if not len(indslice): + continue + + N = len(indslice) + temp = np.stack([dep1slice, dep2slice]) + + vertices_above = np.zeros((N, 2)) + vertices_below = np.zeros((N, 2)) + + vertices_above[0:N, 0] = indslice + vertices_below[0:N, 0] = indslice + + vertices_above[0:N, 1] = np.amax(temp, axis=0) + vertices_below[0:N, 1] = np.amin(temp, axis=0) + + plane_above = mpatches.BoundedSemiplane(vertices_above, 'bottom', + **kwargs) + plane_below = mpatches.BoundedSemiplane(vertices_below, 'top', + **kwargs) + + self.add_artist(plane_above) + self.add_artist(plane_below) + + ret.append(plane_above) + ret.append(plane_below) + + # now update the datalim and autoscale + pts = np.row_stack([np.column_stack([ind[where], dep1[where]]), + np.column_stack([ind[where], dep2[where]])]) + self.update_datalim(pts, updatex=True, updatey=True) + self._request_autoscale_view() + + return ret + + def fill_disjoint(self, x, y1, y2, where=None, interpolate=False, + step=None, **kwargs): + """ + Fill everything not in between the lines *y1* and *y2* + + Parameters + ---------- + x : array (length N) + The x coordinates of the nodes defining the curves. + + y1 : array (length N) or scalar + The y coordinates of the nodes defining the first curve. + + y2 : array (length N) or scalar: default 0 + The y coordinates of the nodes defining the second curve. + + where : array of bool (length N), optional + Define *where* to exclude some regions from being filled. + The filled regions are defined by the coordinates ``x[where]``. + More precisely, fill between ``x[i]`` and ``x[i+1]`` if + ``where[i] and where[i+1]``. Note that this definition implies + that an isolated *True* value between two *False* values in *where* + will not result in filling. Both sides of the *True* position + remain unfilled due to the adjacent *False* values. + + interpolate : bool, default: False + This option is only relevant if *where* is used and the two curves + are crossing each other. + + Semantically, *where* is often used for *y1* > *y2* or + similar. By default, the nodes of the polygon defining the in + between region will only be placed at the positions in the *x* + array. Such a polygon cannot describe the above semantics close to + the intersection. The x-sections containing the intersection are + simply clipped. + + Setting *interpolate* to *True* will calculate the actual + intersection point and extend the filled region up to this point. + + step : {{'pre', 'post', 'mid'}}, optional + Define *step* if the filling should be a step function, + i.e. constant in between *x*. The value determines where the + step will occur: + + - 'pre': The y value is continued constantly to the left from + every *x* position, i.e. the interval ``(x[i-1], x[i]]`` has the + value ``y[i]``. + - 'post': The y value is continued constantly to the right from + every *x* position, i.e. the interval ``[x[i], x[i+1])`` has the + value ``y[i]``. + - 'mid': Steps occur half-way between the *x* positions. + + Other Parameters + ---------------- + **kwargs + All other keyword arguments are passed on to `.BoundedSemiplane`. + %(BoundedSemiplane:kwdoc)s + + Returns + ------- + list of `.BoundedSemiplane` + A list `.BoundedSemiplane` containing the plotted Lines. + + See Also + -------- + fill_between : Fill between two sets of y-values. + fill_betweenx : Fill between two sets of x-values. + """ + return self._fill_above_or_below(x, y1, y2, where=where, + interpolate=interpolate, step=step, + **kwargs) + @_preprocess_data() @_docstring.interpd def imshow(self, X, cmap=None, norm=None, *, aspect=None, diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index f127ab20a97b..e72f63ea137c 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -1066,6 +1066,126 @@ def set_data(self, values=None, edges=None, baseline=None): self.stale = True +class BoundedSemiplane(Patch): + """A semiplane bounded by a polyline on one side.""" + def __str__(self): + if len(self._path.vertices): + s = "Line%d((%g, %g) ...)" + return s % (len(self._path.vertices), *self._path.vertices[0]) + else: + return "Line0()" + + @_docstring.dedent_interpd + def __init__(self, xy, direction, **kwargs): + """ + *xy* is a numpy array with shape Nx2. + + *direction* determines bounding direction of the semiplane + """ + super().__init__(**kwargs) + self.set_direction(direction) + self.set_polyline(xy) + self._update_bounded_path() + + def set_direction(self, direction): + """ + Set the direction of the patch's bound. + + Parameters + ---------- + direction : {{'top', 'bottom'}} + - 'top': Semiplane is upper bounded by the specified polyline + - 'bottom': Semiplane is lower bounded by the specified polyline + """ + if direction not in ['top', 'bottom']: + raise ValueError(f"{direction!r} is not 'top' or 'bottom'") + self._direction = direction + self.stale = True + + def get_direction(self): + """ + Get the direction of the patch's bound. + + Returns + ------- + {{'top', 'bottom'}} + """ + return self._direction + + def set_polyline(self, xy): + """ + Set the vertices of the polyline. + + Parameters + ---------- + xy : (N, 2) array-like + The vertices of polyline. + + Notes + ----- + *xy* is stably sorted by *x* first before setting the polyline. This is + to ensure the fill does not cover any vertices. + """ + xy = np.asarray(xy) + sorted_idxs = np.argsort(xy[:, 0], kind='stable') + xy = xy[sorted_idxs] + self._polyline = xy + self.stale = True + + def get_polyline(self): + """ + Get the bounding polyline. + + Returns + ------- + (N, 2) numpy array + The vertices of polyline. + """ + return self._polyline + + def get_path(self): + """ + Get the `.Path` of the patch. + + Returns + ------- + `.Path` + + Notes + ----- + Returns the `.Path` defined by the bounding polyline if there is no + containing `.Axes`. Otherwise, the path is defined by the polyline, and + two extra endpoints from projecting the polyline's endpoints to the + bound of the axes container. + """ + self._update_bounded_path() + return self._path + + def _update_bounded_path(self): + """ + Updates the bounded path according to the container limits + """ + container = self.axes + if container is None: + self._path = Path(self._polyline) + return + + bottom, top = container.get_ylim() + bound_dict = {'top': bottom, 'bottom': top} + bound = bound_dict[self._direction] + + N, _ = self._polyline.shape + left_x = self._polyline[0, 0] + right_x = self._polyline[N-1, 0] + verts = np.empty((N+2, 2)) + + verts[1:N+1] = self._polyline + verts[0] = [left_x, bound] + verts[N+1] = [right_x, bound] + + self._path = Path(verts) + + class Polygon(Patch): """A general polygon patch.""" diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 79c33a6bacb6..f581baf71939 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -2565,6 +2565,16 @@ def fill_betweenx( **({"data": data} if data is not None else {}), **kwargs) +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@_copy_docstring_and_deprecators(Axes.fill_disjoint) +def fill_disjoint( + x, y1, y2, where=None, interpolate=False, step=None, + **kwargs): + return gca().fill_disjoint( + x, y1, y2, where=where, interpolate=interpolate, step=step, + **kwargs) + + # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.grid) def grid(visible=None, which='major', axis='both', **kwargs): diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint.pdf b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint.pdf new file mode 100644 index 000000000000..091b5fea885d Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint.png b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint.png new file mode 100644 index 000000000000..af83ed43f9d1 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate.pdf b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate.pdf new file mode 100644 index 000000000000..588f80ed06b7 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate.png b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate.png new file mode 100644 index 000000000000..4558f088e4aa Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_decreasing.pdf b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_decreasing.pdf new file mode 100644 index 000000000000..a22268d9ab0c Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_decreasing.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_decreasing.png b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_decreasing.png new file mode 100644 index 000000000000..d259cc1a6f74 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_decreasing.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_nan.pdf b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_nan.pdf new file mode 100644 index 000000000000..0362d60fefd8 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_nan.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_nan.png b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_nan.png new file mode 100644 index 000000000000..1e756a689db0 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_interpolate_nan.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_step.pdf b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_step.pdf new file mode 100644 index 000000000000..f1f273e7a0da Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_step.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_step.png b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_step.png new file mode 100644 index 000000000000..e1247391e71a Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/fill_disjoint_step.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 170605eb1bb6..edb39646ece3 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -46,6 +46,71 @@ # the tests with multiple threads. +@image_comparison(['fill_disjoint'], remove_text=True, style='mpl20') +def test_fill_disjoint(): + # True test + def f1(x): return 32.0 * x + 2.0 + def f2(x): return -55.0 * x + xRng = np.linspace(-1, 1, 100) + plt.plot(xRng, [f1(x) for x in xRng], 'b-') + plt.plot(xRng, [f2(x) for x in xRng], 'r-') + plt.fill_disjoint(xRng, [f1(x) for x in xRng], [f2(x) for x in xRng], + color='g') + + +@image_comparison(['fill_disjoint_interpolate'], remove_text=True, + style='mpl20') +def test_fill_disjoint_interpolate(): + x = np.arange(0.0, 2, 0.02) + y1 = np.sin(2*np.pi*x) + y2 = 1.2*np.sin(4*np.pi*x) + + fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True) + ax1.plot(x, y1, x, y2, color='black') + ax1.fill_disjoint(x, y1, y2, where=y2 >= y1, facecolor='white', hatch='/', + interpolate=True) + ax1.fill_disjoint(x, y1, y2, where=y2 <= y1, facecolor='red', + interpolate=True) + + y2 = np.ma.masked_greater(y2, 1.0) + y2[0] = np.ma.masked + ax2.plot(x, y1, x, y2, color='black') + ax2.fill_disjoint(x, y1, y2, where=y2 >= y1, facecolor='green', + interpolate=True) + ax2.fill_disjoint(x, y1, y2, where=y2 <= y1, facecolor='red', + interpolate=True) + + +@image_comparison(['fill_disjoint_interpolate_nan'], remove_text=True, + style='mpl20') +def test_fill_disjoint_interpolate_nan(): + x = np.arange(10) + y1 = np.asarray([8, 18, np.nan, 18, 8, 18, 24, 18, 8, 18]) + y2 = np.asarray([18, 11, 8, 11, 18, 26, 32, 30, np.nan, np.nan]) + + fig, ax = plt.subplots() + + ax.plot(x, y1, c='k') + ax.plot(x, y2, c='b') + ax.fill_disjoint(x, y1, y2, where=y2 >= y1, facecolor="green", + interpolate=True, alpha=0.5) + ax.fill_disjoint(x, y1, y2, where=y1 >= y2, facecolor="red", + interpolate=True, alpha=0.5) + + +@image_comparison(['fill_disjoint_step'], remove_text=True, + style='mpl20') +def test_fill_disjoint_step(): + x = np.arange(10) + y1 = np.asarray([10, 8, 5, 7, 9, 11, 13, 15, 17, 19]) + y2 = np.asarray([18, 21, 25, 22, 19, 16, 13, 10, 7, 4]) + fig, ax = plt.subplots() + + ax.plot(x, y1, c='k') + ax.plot(x, y2, c='b') + ax.fill_disjoint(x, y1, y2, step="mid", facecolor="red") + + @check_figures_equal(extensions=["png"]) def test_invisible_axes(fig_test, fig_ref): ax = fig_test.subplots() diff --git a/lib/matplotlib/tests/test_patches.py b/lib/matplotlib/tests/test_patches.py index 45bd6b4b06fc..f2fcf4eb51ea 100644 --- a/lib/matplotlib/tests/test_patches.py +++ b/lib/matplotlib/tests/test_patches.py @@ -6,8 +6,9 @@ import pytest import matplotlib as mpl -from matplotlib.patches import (Annulus, Ellipse, Patch, Polygon, Rectangle, - FancyArrowPatch, FancyArrow, BoxStyle, Arc) +from matplotlib.patches import (Annulus, Ellipse, BoundedSemiplane, Patch, + Polygon, Rectangle, FancyArrowPatch, + FancyArrow, BoxStyle, Arc) from matplotlib.testing.decorators import image_comparison, check_figures_equal from matplotlib.transforms import Bbox import matplotlib.pyplot as plt @@ -19,6 +20,53 @@ on_win = (sys.platform == 'win32') +def test_BoundedSemiplane_set_direction(): + xy = [(0, 0), (0, 1), (1, 1)] + l = BoundedSemiplane(xy, 'bottom') + l.stale = False + l.set_direction('top') + assert l.stale + assert l._direction == 'top' + + +def test_BoundedSemiplane_get_direction(): + xy = [(0, 0), (0, 1), (1, 1)] + l = BoundedSemiplane(xy, 'bottom') + assert l.get_direction() + + +def test_BoundedSemiplane_get_xy(): + xy = [(0, 0), (0, 1), (1, 1)] + l = BoundedSemiplane(xy, 'bottom') + l.set_polyline(xy) + assert_array_equal(l.get_polyline(), [[0, 0], [0, 1], [1, 1]]) + + +def test_BoundedSemiplane_update_bounded_path(): + xy = [(0, 0), (0, 1), (1, 1)] + _, ax = plt.subplots() + plt.ylim(0, 6) + plt.xlim(0, 6) + + l_bottom = BoundedSemiplane(xy, 'bottom') + ax.add_patch(l_bottom) + xy1 = np.array([[2, 2], [3, 3], [4, 4]]) + l_bottom._polyline = xy1 + l_bottom._update_bounded_path() + assert_array_equal(l_bottom._path.vertices, [[2, 6], [2, 2], [3, 3], + [4, 4], [4, 6]]) + + +def test_Lines_set_xy(): + xy = [[1, 1], [0, 1], [0, 0]] + xy_sorted = np.array([[0, 1], [0, 0], [1, 1]]) + l = BoundedSemiplane(xy, 'top') + + l.set_polyline(xy) + assert_array_equal(l._polyline, xy_sorted) + assert l.stale + + def test_Polygon_close(): #: GitHub issue #1018 identified a bug in the Polygon handling #: of the closed attribute; the path was not getting closed diff --git a/tools/boilerplate.py b/tools/boilerplate.py index 0b00d7a12b4a..5b2c4608f977 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -251,6 +251,7 @@ def boilerplate_gen(): 'fill', 'fill_between', 'fill_betweenx', + 'fill_disjoint', 'grid', 'hexbin', 'hist',