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..e8f9f07b7eed --- /dev/null +++ b/doc/release/next_whats_new/box_arrow_size_controls.rst @@ -0,0 +1,24 @@ +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. + +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, all containing the text "Arrow". The top left arrow has a shorter head than default, while the top right arrow a longer head. The centre left double arrow has a "road-sign" shape (head as wide as the arrow tail), while the centre right arrow has a "backwards" head. The bottom left arrow has two heads which are larger than default, and the bottom right arrow has a head narrower than its tail. + + import matplotlib.pyplot as plt + + plt.text(0.2, 0.8, "Arrow", ha='center', size=16, bbox=dict(boxstyle="larrow, pad=0.3, head_angle=150")) + plt.text(0.7, 0.8, "Arrow", ha='center', size=16, bbox=dict(boxstyle="rarrow, pad=0.3, head_angle=30")) + plt.text(0.2, 0.2, "Arrow", ha='center', size=16, bbox=dict(boxstyle="darrow, pad=0.3, head_width=3")) + plt.text(0.7, 0.2, "Arrow", ha='center', size=16, bbox=dict(boxstyle="larrow, pad=0.3, head_width=0.5")) + plt.text(0.2, 0.5, "Arrow", ha='center', size=16, bbox=dict(boxstyle="darrow, pad=0.3, head_width=1, head_angle=60")) + plt.text(0.7, 0.5, "Arrow", 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 5cfb16c12715..db11fb71a9dc 100644 --- a/galleries/users_explain/text/annotations.py +++ b/galleries/users_explain/text/annotations.py @@ -234,10 +234,10 @@ # 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 @@ -303,8 +303,8 @@ def custom_box_style(x0, y0, width, height, mutation_size): x0, y0 = x0 - pad, y0 - pad x1, y1 = x0 + width, y0 + height # return the new path - return Path([(x0, y0), (x1, y0), (x1, y1), (x0, y1), - (x0-pad, (y0+y1)/2), (x0, y0), (x0, y0)], + return Path([(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y1-pad), + (x0-pad, (y0+y1)/2), (x0, y0+pad), (x0, y0), (x0, y0)], closed=True) fig, ax = plt.subplots(figsize=(3, 3)) diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index 63453d416b99..209ae4307f4c 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -2508,33 +2508,134 @@ def __call__(self, x0, y0, width, height, mutation_size): class LArrow: """A box in the shape of a left-pointing arrow.""" - def __init__(self, pad=0.3): + def __init__(self, pad=0.3, head_width=1.5, head_angle=90.0): """ Parameters ---------- pad : float, default: 0.3 The amount of padding around the original box. + head_width : float, default: 1.5 + The width of the arrow head, relative to that of the arrow body. + Only positive values are accepted. + head_angle : float, default: 90.0 + The angle subtended by the tip of the arrow head, in degrees. + Only nonzero angles are accepted. """ self.pad = pad + if head_width < 0: + raise ValueError("The relative head width must be a positive number.") + + self.head_width = head_width + + # Set arrow-head angle to within [0, 360 deg) + self.head_angle = head_angle % 360. + + if self.head_angle == 0: + # This would cause a division by zero ('infinitely long' arrow head) + raise ValueError("Head angle of zero is not valid.") + def __call__(self, x0, y0, width, height, mutation_size): - # padding + # scaled padding pad = mutation_size * self.pad - # width and height with padding added. + # add padding to width and height width, height = width + 2 * pad, height + 2 * pad - # boundary of the padded box + # boundary points of the padded box (used as arrow shaft) x0, y0 = x0 - pad, y0 - pad, x1, y1 = x0 + width, y0 + height - + # half-width and quarter-width of arrow shaft 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)]) + # Pad to position original box and its margins exactly inside arrow shaft + padding_offset = (0.5 * pad) + (0.25 * mutation_size) + x0 -= padding_offset + + # The width adjustment is the distance that must be subtracted from + # y0 and added to y1 to reach the non-tip vertices of the head. + # The body width is 2dx. + # Subtracting 1 from the head width gives, in units of the body width, + # the total 'width' of arrow-head not within the body. + width_adjustment = (self.head_width - 1) * dx + + if self.head_angle <= 180: + # Non-reversed arrow head (<---) + + # tan(1/2 * angle subtended by arrow tip) + tan_half_angle = np.tan(self.head_angle * (math.pi / 360)) + + # The angle adjustment is the tip-to-body length of the arrow head. + # Each half of the arrow head is a right-angled triangle. Therefore, + # each half of the arrow head has, by trigonometry, tan(head_angle/2)= + # (dx+width_adjustment)/(dxx+angle_adjustment). + angle_adjustment = ((dx + width_adjustment) / tan_half_angle) - dxx + + # If there is sufficient space available, shorten the arrow shaft to + # push some of the padding margin into the head + if self.head_width > 1 and pad * tan_half_angle < width_adjustment: + # Pad original box into head + x0 += pad + + return Path._create_closed([ + (x0 + dxx, y0), + (x1, y0), + (x1, y1), + (x0 + dxx, y1), + (x0 + dxx, y1 + width_adjustment), + (x0 - angle_adjustment, y0 + dx), + (x0 + dxx, y0 - width_adjustment), + (x0 + dxx, y0) + ]) + else: + # Reversed arrow head (>---) + + # tan(1/2 * angle subtended by arrow tip) + tan_half_angle = -np.tan(self.head_angle * (math.pi / 360)) + + if self.head_width <= 1: + # Rectangle; head entirely enclosed by body (don't count head + # 'poking' out of back of body) + + return Path._create_closed([ + (x0 + dxx, y0), + (x1, y0), + (x1, y1), + (x0 + dxx, y1), + (x0 + dxx, y0) + ]) + + + # Distance from end of arrow to points where slanted parts of head + # intercept arrow body + intercept_adjustment = width_adjustment / tan_half_angle + + if intercept_adjustment < width: + # Some of arrow body is outside of head + + return Path._create_closed([ + (x0 + dxx, y0 - width_adjustment), + (x0 + dxx + intercept_adjustment, y0), + (x1, y0), + (x1, y1), + (x0 + dxx + intercept_adjustment, y1), + (x0 + dxx, y1 + width_adjustment), + (x0 + dxx, y0 - width_adjustment) + ]) + else: + # Trapezium-shaped reversed arrow (reversed triangle 'cut off' by + # end of body + + # Vertical distance between top of original box at end furthest from + # arrow head and corner of trapezium + vertical_offset = width_adjustment + ((x0 - x1) * tan_half_angle) + + return Path._create_closed([ + (x0 + dxx, y0 - width_adjustment), + (x1, y0 - vertical_offset), + (x1, y1 + vertical_offset), + (x0 + dxx, y1 + width_adjustment), + (x0 + dxx, y0 - width_adjustment) + ]) @_register_style(_style_list) class RArrow(LArrow): @@ -2551,37 +2652,141 @@ class DArrow: """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): + def __init__(self, pad=0.3, head_width=1.5, head_angle=90.0): """ Parameters ---------- pad : float, default: 0.3 The amount of padding around the original box. + head_width : float, default: 1.5 + The width of each arrow head, relative to that of the arrow body. + Only positive values are accepted. + # This would cause a division by zero ('infinitely long' arrow head) + raise ValueError("Head angle of zero is not valid.") are accepted. + head_angle : float, default: 90.0 + The angle subtended by the tip of each arrow head, in degrees. + Only nonzero angles are accepted. """ self.pad = pad + if head_width < 0: + raise ValueError("The relative head width must be a positive number.") + + self.head_width = head_width + + # Set arrow-head angle to within [0, 360 deg) + self.head_angle = head_angle % 360. + + if self.head_angle == 0: + # This would cause a division by zero ('infinitely long' arrow head) + raise ValueError("Head angle of zero is not valid.") + def __call__(self, x0, y0, width, height, mutation_size): - # padding + # scaled padding 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. + # add padding to height height = height + 2 * pad - # boundary of the padded box + # boundary points of the padded box (used as arrow shaft) x0, y0 = x0 - pad, y0 - pad x1, y1 = x0 + width, y0 + height - + # half-width and quarter-width of arrow shaft 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)]) + + # Pad original box + padding_offset = (0.5 * pad) + (0.25 * mutation_size) + x0 -= padding_offset + x1 += 2 * pad + + # The width adjustment is the distance that must be subtracted from + # y0 and added to y1 to reach the non-tip vertices of the head. + # The body width is 2dx. + # Subtracting 1 from the head width gives, in units of the body width, + # the total 'width' of arrow-head not within the body. + width_adjustment = (self.head_width - 1) * dx + + if self.head_angle <= 180: + # Non-reversed arrow heads (<--->) + + # tan(1/2 * angle subtended by arrow tip) + tan_half_angle = np.tan(self.head_angle * (math.pi / 360)) + + # The angle adjustment is the tip-to-body length of the arrow head. + # Each half of the arrow head is a right-angled triangle. Therefore, + # each half of the arrow head has, by trigonometry, tan(head_angle/2)= + # (dx+width_adjustment)/(dxx+angle_adjustment). + angle_adjustment = ((dx + width_adjustment) / tan_half_angle) - dxx + + # If there is sufficient space available, shorten the arrow shaft to + # push some of the padding margin into the heads + if self.head_width > 1 and pad * tan_half_angle < width_adjustment: + # Pad original box into heads + x0 += pad + x1 -= pad + + return Path._create_closed([ + (x0 + dxx, y0), + (x1, y0), + (x1, y0 - width_adjustment), + (x1 + dxx + angle_adjustment, y0 + dx), + (x1, y1 + width_adjustment), + (x1, y1), + (x0 + dxx, y1), + (x0 + dxx, y1 + width_adjustment), + (x0 - angle_adjustment, y0 + dx), + (x0 + dxx, y0 - width_adjustment), + (x0 + dxx, y0) + ]) + else: + # Reversed arrow heads (>---<) + + # tan(1/2 * angle subtended by arrow tip) + tan_half_angle = -np.tan(self.head_angle * (math.pi / 360)) + + if self.head_width <= 1: + # Rectangle; heads entirely enclosed by body + + return Path._create_closed([ + (x0 + dxx, y0), + (x1, y0), + (x1, y1), + (x0 + dxx, y1), + (x0 + dxx, y0) + ]) + + # Distance from end of arrow to points where slanted parts of head + # intercept arrow body + intercept_adjustment = width_adjustment / tan_half_angle + + if (2 * intercept_adjustment) < width: + # Some of arrow body is outside of heads + + return Path._create_closed([ + (x0 + dxx, y0 - width_adjustment), + (x0 + dxx + intercept_adjustment, y0), + (x1 - intercept_adjustment, y0), + (x1, y0 - width_adjustment), + (x1, y1 + width_adjustment), + (x1 - intercept_adjustment, y1), + (x0 + dxx + intercept_adjustment, y1), + (x0 + dxx, y1 + width_adjustment), + (x0 + dxx, y0 - width_adjustment) + ]) + else: + # Draw overlapping arrow heads + + # y-offset inwards of central points + centre_offset = (width * tan_half_angle) / 2 + + return Path._create_closed([ + (x0 + dxx, y0 - width_adjustment), + ((x0 + x1 + dxx) / 2, y0 - width_adjustment + centre_offset), + (x1, y0 - width_adjustment), + (x1, y1 + width_adjustment), + ((x0 + x1 + dxx) / 2, y1 + width_adjustment - centre_offset), + (x0 + dxx, y1 + width_adjustment), + (x0 + dxx, y0 - width_adjustment) + ]) @_register_style(_style_list) class Round: diff --git a/lib/matplotlib/patches.pyi b/lib/matplotlib/patches.pyi index c95f20e35812..deff122a506e 100644 --- a/lib/matplotlib/patches.pyi +++ b/lib/matplotlib/patches.pyi @@ -378,7 +378,11 @@ class BoxStyle(_Style): class LArrow(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, @@ -400,7 +404,11 @@ class BoxStyle(_Style): class DArrow(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, 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..ad69906b4028 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..c626bb360ceb 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 c2b6d4fa8086..56a2a4b560fc 100644 --- a/lib/matplotlib/tests/test_arrow_patches.py +++ b/lib/matplotlib/tests/test_arrow_patches.py @@ -49,6 +49,47 @@ 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