diff --git a/doc/users/next_whats_new/box_arrow_size_controls.rst b/doc/users/next_whats_new/box_arrow_size_controls.rst new file mode 100644 index 000000000000..99acd10e1df7 --- /dev/null +++ b/doc/users/next_whats_new/box_arrow_size_controls.rst @@ -0,0 +1,48 @@ +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: false + :alt: A plot containing two arrow-shaped text boxes. One arrow has a pentagonal 'road-sign' shape, and the other an inverted arrow head on each end. + + import numpy as np + import matplotlib.pyplot as plt + + # Data for plotting; here, an intensity distribution for Fraunhofer diffraction + # from 7 thin slits + x_data = np.linspace(-3 * np.pi, 3 * np.pi, num=1000) + I_data = (np.sin(x_data * 3.5) / np.sin(x_data / 2)) ** 2 + + # Generate plot + + fig, ax = plt.subplots() + plt.plot(x_data, I_data) + + plt.xlim(-3 * np.pi, 3 * np.pi) + plt.ylim(0, 50) + + # + # Annotate with boxed text in arrows + # + + # head_width=1 gives 'road-sign' shape + t1 = ax.text(-1, 35, "Primary maximum", + ha="right", va="center", rotation=30, size=10, + bbox=dict(boxstyle="rarrow,pad=0.3,head_width=1,head_angle=60", + fc="lightblue", ec="steelblue", lw=2)) + + # Negative head_angle gives reversed arrow heads + t2 = ax.text(np.pi, 30, " Lower intensity ", + ha="center", va="center", rotation=0, size=10, + bbox=dict(boxstyle="darrow,pad=0.3,head_width=2,head_angle=-80", + fc="lightblue", ec="steelblue", lw=2)) + + + plt.show() diff --git a/galleries/users_explain/text/annotations.py b/galleries/users_explain/text/annotations.py index 5cfb16c12715..8c158a3095ab 100644 --- a/galleries/users_explain/text/annotations.py +++ b/galleries/users_explain/text/annotations.py @@ -230,20 +230,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 -# Ellipse ``ellipse`` pad=0.3 -# LArrow ``larrow`` pad=0.3 -# RArrow ``rarrow`` pad=0.3 -# 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 -# ========== ============== ========================== +# ========== ============== ========================== +# Class Name Attrs +# ========== ============== ========================== +# Circle ``circle`` 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,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 @@ -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..e3bcff7d015e 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -2508,33 +2508,136 @@ 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.") + else: + self.head_width = head_width + + # Set arrow-head angle to within [0, 360 deg) + self.head_angle = np.mod(head_angle, 360.) + 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 (arrow tail/body) x0, y0 = x0 - pad, y0 - pad, x1, y1 = x0 + width, y0 + height - + # half-width and quarter-width of arrow tail 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)]) + # 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 == 0: + # This would cause a division by zero ('infinitely long' arrow head) + raise ValueError("Head angle of zero is not valid.") + elif self.head_angle <= 180: + # Non-reversed arrow head (<---) + + # 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) / math.tan((self. + head_angle/2) * (math.pi/180))) - dxx + + 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), # arrow + (x0 + dxx, y0)]) + else: + # Reversed arrow head (>---) + + # No arrow head available for enclosed text to spill over into; add + # padding to left of text + x0 = x0 - (1.4 * pad) + + # tan(1/2 * angle subtended by arrow tip) + tan_half_angle = -np.tan(self.head_angle * (math.pi / 360)) + + if self.head_width <= 1: + # Length of arrow head + angle_adjustment = (self.head_width * dx) / tan_half_angle + + if angle_adjustment < width: + # Rectangle; head entirely enclosed by body + + return Path._create_closed([ + (x0 + dxx, y0), + (x1, y0), + (x1, y1), + (x0 + dxx, y1), + (x0 + dxx, y0) + ]) + else: + # Head pokes out of back of body + + # The half-width of the connection of the arrow tip to the + # back of the arrow body + opposite_width_adjustment = (angle_adjustment - width) \ + * tan_half_angle + + return Path._create_closed([ + (x0 + dxx, y0), + (x1, y0), + (x1, y0 + dx - opposite_width_adjustment), + (x0 + dxx + angle_adjustment, y0 + dx), + (x1, y0 + dx + opposite_width_adjustment), + (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: + # Draw triangle + + return Path._create_closed([ + (x0 + dxx, y0 - width_adjustment), + (x0 + dxx + intercept_adjustment, y0 + dx), + (x0 + dxx, y1 + width_adjustment), + (x0 + dxx, y0 - width_adjustment) + ]) @_register_style(_style_list) class RArrow(LArrow): @@ -2551,37 +2654,155 @@ 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. + 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.") + else: + self.head_width = head_width + + # Set arrow-head angle to within [0, 360 deg) + self.head_angle = np.mod(head_angle, 360.) + 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 (arrow tail/body) x0, y0 = x0 - pad, y0 - pad x1, y1 = x0 + width, y0 + height - + # half-width and quarter-width of arrow tail 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)]) + # 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 == 0: + # This would cause a division by zero ('infinitely long' arrow head) + raise ValueError("Head angle of zero is not valid.") + elif self.head_angle <= 180: + # Non-reversed arrow heads (<--->) + + # 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) / math.tan((self. + head_angle/2) * (math.pi/180))) - dxx + + 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 (>---<) + + # No arrow head available for enclosed text to spill over into; add + # padding to left of text + x0 = x0 - (1.4 * pad) + + # tan(1/2 * angle subtended by arrow tip) + tan_half_angle = -np.tan(self.head_angle * (math.pi / 360)) + + if self.head_width <= 1: + # Length of arrow head + angle_adjustment = (self.head_width * dx) / tan_half_angle + + if angle_adjustment < width: + # Rectangle; heads entirely enclosed by body + + return Path._create_closed([ + (x0 + dxx, y0), + (x1, y0), + (x1, y1), + (x0 + dxx, y1), + (x0 + dxx, y0) + ]) + else: + # Heads poke out of opposite ends of body + + # The half-width of the connection of the arrow tip to the + # back of the arrow body + opposite_width_adjustment = (angle_adjustment - width) \ + * tan_half_angle + + return Path._create_closed([ + (x0 + dxx, y0), + (x1, y0), + (x1, y0 + dx - opposite_width_adjustment), + (x0 + dxx + angle_adjustment, y0 + dx), + (x1, y0 + dx + opposite_width_adjustment), + (x1, y1), + (x0 + dxx, y1), + (x0 + dxx, y0 + dx + opposite_width_adjustment), + (x1 - angle_adjustment, y0 + dx), + (x0 + dxx, y0 + dx - opposite_width_adjustment), + (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..3b1d95ee9fe8 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/test_arrow_patches.py b/lib/matplotlib/tests/test_arrow_patches.py index c2b6d4fa8086..3a917b42033a 100644 --- a/lib/matplotlib/tests/test_arrow_patches.py +++ b/lib/matplotlib/tests/test_arrow_patches.py @@ -49,6 +49,36 @@ 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 = mpatches.BoxStyle.get_styles() + + repetitions = 2 + spacing = 2.4 + + figheight = (3 * repetitions * spacing + .5) + fig = plt.figure(figsize=(4 / 1.5, figheight / 1.5)) + + fontsize = 0.3 * 72 + + angle = -80 + angle_step = 120 / repetitions + for i in range(repetitions): + for j, stylename in enumerate(sorted(styles)): + if stylename in ("larrow", "rarrow", "darrow"): + fig.text(0.5, ((3 * repetitions - i - j) * spacing - 0.5)/figheight, + stylename, + ha="center", + size=fontsize, + transform=fig.transFigure, + rotation=angle, + bbox=dict(boxstyle=stylename+\ + f",head_width={(i + j) / 2},head_angle={angle}", + fc="w", ec="k")) + angle = angle + angle_step + + def __prepare_fancyarrow_dpi_cor_test(): """ Convenience function that prepares and returns a FancyArrowPatch. It aims