diff --git a/doc/release/next_whats_new/box_arrow_size_controls.rst b/doc/release/next_whats_new/box_arrow_size_controls.rst new file mode 100644 index 000000000000..6fb2b69e49fd --- /dev/null +++ b/doc/release/next_whats_new/box_arrow_size_controls.rst @@ -0,0 +1,37 @@ +Arrow-style sub-classes of ``BoxStyle`` support arrow head resizing +------------------------------------------------------------------- + +The new *head_width* and *head_angle* parameters to +`.BoxStyle.LArrow`, `.BoxStyle.RArrow` and `.BoxStyle.DArrow` allow for adjustment +of the size and aspect ratio of the arrow heads used. + +To give a consistent appearance across all parameter values, the +default head position (where the head starts relative to text) is +slightly changed compared to the previous hard-coded position. + +By using negative angles (or corresponding reflex angles) for *head_angle*, arrows +with 'backwards' heads may be created. + +.. plot:: + :include-source: true + :alt: + Six arrow-shaped text boxes. The arrows on the left have the shaft on + their left; the arrows on the right have the shaft on the right; the + arrows in the middle have shafts on both sides. + + import matplotlib.pyplot as plt + + plt.text(0.2, 0.8, "LArrow", ha='center', size=16, + bbox=dict(boxstyle="larrow, pad=0.3, head_angle=150")) + plt.text(0.2, 0.2, "LArrow", ha='center', size=16, + bbox=dict(boxstyle="larrow, pad=0.3, head_width=0.5")) + plt.text(0.5, 0.8, "DArrow", ha='center', size=16, + bbox=dict(boxstyle="darrow, pad=0.3, head_width=3")) + plt.text(0.5, 0.2, "DArrow", ha='center', size=16, + bbox=dict(boxstyle="darrow, pad=0.3, head_width=1, head_angle=60")) + plt.text(0.8, 0.8, "RArrow", ha='center', size=16, + bbox=dict(boxstyle="rarrow, pad=0.3, head_angle=30")) + plt.text(0.8, 0.2, "RArrow", ha='center', size=16, + bbox=dict(boxstyle="rarrow, pad=0.3, head_width=2, head_angle=-90")) + + plt.show() diff --git a/galleries/users_explain/text/annotations.py b/galleries/users_explain/text/annotations.py index 7c25aa9eed9a..5221c6c90e12 100644 --- a/galleries/users_explain/text/annotations.py +++ b/galleries/users_explain/text/annotations.py @@ -231,20 +231,20 @@ # The arguments are the name of the box style with its attributes as # keyword arguments. Currently, following box styles are implemented: # -# ========== ============== ========================== +# ========== ============== ==================================== # Class Name Attrs -# ========== ============== ========================== +# ========== ============== ==================================== # Circle ``circle`` pad=0.3 -# DArrow ``darrow`` pad=0.3 +# DArrow ``darrow`` pad=0.3,head_width=1.5,head_angle=90 # Ellipse ``ellipse`` pad=0.3 -# LArrow ``larrow`` pad=0.3 -# RArrow ``rarrow`` pad=0.3 +# LArrow ``larrow`` pad=0.3,head_width=1.5,head_angle=90 +# RArrow ``rarrow`` pad=0.3,head_width=1.5,head_angle=90 # Round ``round`` pad=0.3,rounding_size=None # Round4 ``round4`` pad=0.3,rounding_size=None # Roundtooth ``roundtooth`` pad=0.3,tooth_size=None # Sawtooth ``sawtooth`` pad=0.3,tooth_size=None # Square ``square`` pad=0.3 -# ========== ============== ========================== +# ========== ============== ==================================== # # .. figure:: /gallery/shapes_and_collections/images/sphx_glr_fancybox_demo_001.png # :target: /gallery/shapes_and_collections/fancybox_demo.html diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 8dabb548642d..cdd03bcd7e89 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -2587,83 +2587,124 @@ def __call__(self, x0, y0, width, height, mutation_size): return trans.transform_path(Path.unit_circle()) @_register_style(_style_list) - class LArrow: - """A box in the shape of a left-pointing arrow.""" + class RArrow: + """A box in the shape of a right-pointing arrow.""" - def __init__(self, pad=0.3): + def __init__(self, pad=0.3, head_width=1.5, head_angle=90): """ Parameters ---------- pad : float, default: 0.3 The amount of padding around the original box. + head_width : float, default: 1.5 + The head width, relative to the arrow shaft width; must be + nonnegative. + head_angle : float, default: 90 + The angle at the tip of the arrow, in degrees; must be nonzero + (modulo 360). Negative angles result in arrow heads pointing + backwards. """ self.pad = pad + if head_width < 0: + raise ValueError("'head_width' must be nonnegative") + self.head_width = head_width + if head_angle % 360 == 0: + raise ValueError("'head_angle' must be nonzero") + self.head_angle = head_angle def __call__(self, x0, y0, width, height, mutation_size): - # padding + # padding & padded dimensions pad = mutation_size * self.pad - # width and height with padding added. - width, height = width + 2 * pad, height + 2 * pad - # boundary of the padded box + dx, dy = width + 2 * pad, height + 2 * pad x0, y0 = x0 - pad, y0 - pad, - x1, y1 = x0 + width, y0 + height - - dx = (y1 - y0) / 2 - dxx = dx / 2 - x0 = x0 + pad / 1.4 # adjust by ~sqrt(2) - - return Path._create_closed( - [(x0 + dxx, y0), (x1, y0), (x1, y1), (x0 + dxx, y1), - (x0 + dxx, y1 + dxx), (x0 - dx, y0 + dx), - (x0 + dxx, y0 - dxx), # arrow - (x0 + dxx, y0)]) + x1, y1 = x0 + dx, y0 + dy + + head_dy = self.head_width * dy + mid_y = (y0 + y1) / 2 + shaft_y0 = mid_y - head_dy / 2 + shaft_y1 = mid_y + head_dy / 2 + + cot = 1 / math.tan(math.radians(self.head_angle / 2)) + + if cot > 0: + # tip_x is chosen s.t. the angled line moving back from the tip hits + # i) if head_width > 1: the box corner, or ii) if head_width < + # 1 the box edge at the point giving the correct shaft width. + tip_x = x1 + cot * min(dy, head_dy) / 2 + shaft_x = tip_x - cot * head_dy / 2 + return Path._create_closed([ + (x0, y0), (shaft_x, y0), (shaft_x, shaft_y0), + (tip_x, mid_y), + (shaft_x, shaft_y1), (shaft_x, y1), (x0, y1), + ]) + else: # Reverse arrowhead. + # Make the long (outer) side of the arrowhead flush with the + # original box, and move back accordingly (but clipped to no + # more than the box length). If this clipping is necessary, + # the y positions at the short (inner) side of the arrowhead + # will be thicker than the original box, hence the need to + # recompute mid_y0 & mid_y1. + # If head_width < 1 no arrowhead is drawn. + dx = min(-cot * max(head_dy - dy, 0) / 2, dx) # cot < 0! + mid_y0 = min(shaft_y0, y0) - dx / cot + mid_y1 = max(shaft_y1, y1) + dx / cot + return Path._create_closed([ + (x0, y0), (x1 - dx, mid_y0), (x1, shaft_y0), + (x1, shaft_y1), (x1 - dx, mid_y1), (x0, y1), + ]) @_register_style(_style_list) - class RArrow(LArrow): - """A box in the shape of a right-pointing arrow.""" + class LArrow(RArrow): + """A box in the shape of a left-pointing arrow.""" def __call__(self, x0, y0, width, height, mutation_size): - p = BoxStyle.LArrow.__call__( - self, x0, y0, width, height, mutation_size) + p = super().__call__(x0, y0, width, height, mutation_size) p.vertices[:, 0] = 2 * x0 + width - p.vertices[:, 0] return p @_register_style(_style_list) - class DArrow: + class DArrow(RArrow): """A box in the shape of a two-way arrow.""" - # Modified from LArrow to add a right arrow to the bbox. - - def __init__(self, pad=0.3): - """ - Parameters - ---------- - pad : float, default: 0.3 - The amount of padding around the original box. - """ - self.pad = pad + # Modified from RArrow to have arrows on both sides; see comments above. def __call__(self, x0, y0, width, height, mutation_size): - # padding + # padding & padded dimensions pad = mutation_size * self.pad - # width and height with padding added. - # The width is padded by the arrows, so we don't need to pad it. - height = height + 2 * pad - # boundary of the padded box - x0, y0 = x0 - pad, y0 - pad - x1, y1 = x0 + width, y0 + height - - dx = (y1 - y0) / 2 - dxx = dx / 2 - x0 = x0 + pad / 1.4 # adjust by ~sqrt(2) - - return Path._create_closed([ - (x0 + dxx, y0), (x1, y0), # bot-segment - (x1, y0 - dxx), (x1 + dx + dxx, y0 + dx), - (x1, y1 + dxx), # right-arrow - (x1, y1), (x0 + dxx, y1), # top-segment - (x0 + dxx, y1 + dxx), (x0 - dx, y0 + dx), - (x0 + dxx, y0 - dxx), # left-arrow - (x0 + dxx, y0)]) + dx, dy = width + 2 * pad, height + 2 * pad + x0, y0 = x0 - pad, y0 - pad, + x1, y1 = x0 + dx, y0 + dy + + head_dy = self.head_width * dy + mid_y = (y0 + y1) / 2 + shaft_y0 = mid_y - head_dy / 2 + shaft_y1 = mid_y + head_dy / 2 + + cot = 1 / math.tan(math.radians(self.head_angle / 2)) + + if cot > 0: + tip_x0 = x0 - cot * min(dy, head_dy) / 2 + shaft_x0 = tip_x0 + cot * head_dy / 2 + tip_x1 = x1 + cot * min(dy, head_dy) / 2 + shaft_x1 = tip_x1 - cot * head_dy / 2 + return Path._create_closed([ + (shaft_x0, y1), (shaft_x0, shaft_y1), + (tip_x0, mid_y), + (shaft_x0, shaft_y0), (shaft_x0, y0), + (shaft_x1, y0), (shaft_x1, shaft_y0), + (tip_x1, mid_y), + (shaft_x1, shaft_y1), (shaft_x1, y1), + ]) + else: + # Don't move back by more than half the box length. + dx = min(-cot * max(head_dy - dy, 0) / 2, dx / 2) # cot < 0! + mid_y0 = min(shaft_y0, y0) - dx / cot + mid_y1 = max(shaft_y1, y1) + dx / cot + return Path._create_closed([ + (x0, shaft_y0), (x0 + dx, mid_y0), + (x1 - dx, mid_y0), (x1, shaft_y0), + (x1, shaft_y1), (x1 - dx, mid_y1), + (x0 + dx, mid_y1), (x0, shaft_y1), + ]) @_register_style(_style_list) class Round: diff --git a/lib/matplotlib/patches.pyi b/lib/matplotlib/patches.pyi index 4f44d142074f..862eccf4da64 100644 --- a/lib/matplotlib/patches.pyi +++ b/lib/matplotlib/patches.pyi @@ -379,9 +379,13 @@ class BoxStyle(_Style): mutation_size: float, ) -> Path: ... - class LArrow(BoxStyle): + class RArrow(BoxStyle): pad: float - def __init__(self, pad: float = ...) -> None: ... + head_width: float + head_angle: float + def __init__( + self, pad: float = ..., head_width: float = ..., head_angle: float = ... + ) -> None: ... def __call__( self, x0: float, @@ -391,7 +395,7 @@ class BoxStyle(_Style): mutation_size: float, ) -> Path: ... - class RArrow(LArrow): + class LArrow(RArrow): def __call__( self, x0: float, @@ -401,9 +405,13 @@ class BoxStyle(_Style): mutation_size: float, ) -> Path: ... - class DArrow(BoxStyle): + class DArrow(RArrow): pad: float - def __init__(self, pad: float = ...) -> None: ... + head_width: float + head_angle: float + def __init__( + self, pad: float = ..., head_width: float = ..., head_angle: float = ... + ) -> None: ... def __call__( self, x0: float, diff --git a/lib/matplotlib/tests/baseline_images/test_arrow_patches/boxarrow_adjustment_test_image.png b/lib/matplotlib/tests/baseline_images/test_arrow_patches/boxarrow_adjustment_test_image.png new file mode 100644 index 000000000000..02b8d39d6b98 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_arrow_patches/boxarrow_adjustment_test_image.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_arrow_patches/boxarrow_test_image.png b/lib/matplotlib/tests/baseline_images/test_arrow_patches/boxarrow_test_image.png index 11ad0b89b4db..2fa6b71900f7 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_arrow_patches/boxarrow_test_image.png and b/lib/matplotlib/tests/baseline_images/test_arrow_patches/boxarrow_test_image.png differ diff --git a/lib/matplotlib/tests/test_arrow_patches.py b/lib/matplotlib/tests/test_arrow_patches.py index e26c806c9ea4..9cf1636f7913 100644 --- a/lib/matplotlib/tests/test_arrow_patches.py +++ b/lib/matplotlib/tests/test_arrow_patches.py @@ -49,6 +49,48 @@ def test_boxarrow(): bbox=dict(boxstyle=stylename, fc="w", ec="k")) +@image_comparison(['boxarrow_adjustment_test_image.png'], style='mpl20') +def test_boxarrow_adjustment(): + + styles = ['larrow', 'rarrow', 'darrow'] + + # Cases [head_width, head_angle] to test for each arrow style + cases = [ + [1.5, 90], + [1.5, 170], # Test dynamic padding + [0.75, 30], + [0.5, -10], # Should just give a rectangle + [2, -90], + [2, -15] # None of arrow body is outside of head + ] + + # Horizontal and vertical spacings of arrow centres + spacing_horizontal = 3.75 + spacing_vertical = 1.6 + + # Numbers of styles and cases + m = len(styles) + n = len(cases) + + figwidth = (m * spacing_horizontal) + figheight = (n * spacing_vertical) + .5 + + fig = plt.figure(figsize=(figwidth / 1.5, figheight / 1.5)) + + fontsize = 0.3 * 72 + + for i, stylename in enumerate(styles): + for j, case in enumerate(cases): + # Draw arrow + fig.text( + ((m - i) * spacing_horizontal - 1.5) / figwidth, + ((n - j) * spacing_vertical - 0.5) / figheight, + stylename, ha='center', va='center', + size=fontsize, transform=fig.transFigure, + bbox=dict(boxstyle=f"{stylename}, head_width={case[0]}, \ + head_angle={case[1]}", fc="w", ec="k")) + + def __prepare_fancyarrow_dpi_cor_test(): """ Convenience function that prepares and returns a FancyArrowPatch. It aims