Skip to content

Commit 49fd492

Browse files
authored
Merge pull request matplotlib#31198 from anntzer/roadsign
Allow tuning the shape of {L,R,D}Arrow tips.
2 parents 2ad0aa7 + 48a465e commit 49fd492

File tree

7 files changed

+192
-64
lines changed

7 files changed

+192
-64
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Arrow-style sub-classes of ``BoxStyle`` support arrow head resizing
2+
-------------------------------------------------------------------
3+
4+
The new *head_width* and *head_angle* parameters to
5+
`.BoxStyle.LArrow`, `.BoxStyle.RArrow` and `.BoxStyle.DArrow` allow for adjustment
6+
of the size and aspect ratio of the arrow heads used.
7+
8+
To give a consistent appearance across all parameter values, the
9+
default head position (where the head starts relative to text) is
10+
slightly changed compared to the previous hard-coded position.
11+
12+
By using negative angles (or corresponding reflex angles) for *head_angle*, arrows
13+
with 'backwards' heads may be created.
14+
15+
.. plot::
16+
:include-source: true
17+
:alt:
18+
Six arrow-shaped text boxes. The arrows on the left have the shaft on
19+
their left; the arrows on the right have the shaft on the right; the
20+
arrows in the middle have shafts on both sides.
21+
22+
import matplotlib.pyplot as plt
23+
24+
plt.text(0.2, 0.8, "LArrow", ha='center', size=16,
25+
bbox=dict(boxstyle="larrow, pad=0.3, head_angle=150"))
26+
plt.text(0.2, 0.2, "LArrow", ha='center', size=16,
27+
bbox=dict(boxstyle="larrow, pad=0.3, head_width=0.5"))
28+
plt.text(0.5, 0.8, "DArrow", ha='center', size=16,
29+
bbox=dict(boxstyle="darrow, pad=0.3, head_width=3"))
30+
plt.text(0.5, 0.2, "DArrow", ha='center', size=16,
31+
bbox=dict(boxstyle="darrow, pad=0.3, head_width=1, head_angle=60"))
32+
plt.text(0.8, 0.8, "RArrow", ha='center', size=16,
33+
bbox=dict(boxstyle="rarrow, pad=0.3, head_angle=30"))
34+
plt.text(0.8, 0.2, "RArrow", ha='center', size=16,
35+
bbox=dict(boxstyle="rarrow, pad=0.3, head_width=2, head_angle=-90"))
36+
37+
plt.show()

galleries/users_explain/text/annotations.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,20 +231,20 @@
231231
# The arguments are the name of the box style with its attributes as
232232
# keyword arguments. Currently, following box styles are implemented:
233233
#
234-
# ========== ============== ==========================
234+
# ========== ============== ====================================
235235
# Class Name Attrs
236-
# ========== ============== ==========================
236+
# ========== ============== ====================================
237237
# Circle ``circle`` pad=0.3
238-
# DArrow ``darrow`` pad=0.3
238+
# DArrow ``darrow`` pad=0.3,head_width=1.5,head_angle=90
239239
# Ellipse ``ellipse`` pad=0.3
240-
# LArrow ``larrow`` pad=0.3
241-
# RArrow ``rarrow`` pad=0.3
240+
# LArrow ``larrow`` pad=0.3,head_width=1.5,head_angle=90
241+
# RArrow ``rarrow`` pad=0.3,head_width=1.5,head_angle=90
242242
# Round ``round`` pad=0.3,rounding_size=None
243243
# Round4 ``round4`` pad=0.3,rounding_size=None
244244
# Roundtooth ``roundtooth`` pad=0.3,tooth_size=None
245245
# Sawtooth ``sawtooth`` pad=0.3,tooth_size=None
246246
# Square ``square`` pad=0.3
247-
# ========== ============== ==========================
247+
# ========== ============== ====================================
248248
#
249249
# .. figure:: /gallery/shapes_and_collections/images/sphx_glr_fancybox_demo_001.png
250250
# :target: /gallery/shapes_and_collections/fancybox_demo.html

lib/matplotlib/patches.py

Lines changed: 94 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2587,83 +2587,124 @@ def __call__(self, x0, y0, width, height, mutation_size):
25872587
return trans.transform_path(Path.unit_circle())
25882588

25892589
@_register_style(_style_list)
2590-
class LArrow:
2591-
"""A box in the shape of a left-pointing arrow."""
2590+
class RArrow:
2591+
"""A box in the shape of a right-pointing arrow."""
25922592

2593-
def __init__(self, pad=0.3):
2593+
def __init__(self, pad=0.3, head_width=1.5, head_angle=90):
25942594
"""
25952595
Parameters
25962596
----------
25972597
pad : float, default: 0.3
25982598
The amount of padding around the original box.
2599+
head_width : float, default: 1.5
2600+
The head width, relative to the arrow shaft width; must be
2601+
nonnegative.
2602+
head_angle : float, default: 90
2603+
The angle at the tip of the arrow, in degrees; must be nonzero
2604+
(modulo 360). Negative angles result in arrow heads pointing
2605+
backwards.
25992606
"""
26002607
self.pad = pad
2608+
if head_width < 0:
2609+
raise ValueError("'head_width' must be nonnegative")
2610+
self.head_width = head_width
2611+
if head_angle % 360 == 0:
2612+
raise ValueError("'head_angle' must be nonzero")
2613+
self.head_angle = head_angle
26012614

26022615
def __call__(self, x0, y0, width, height, mutation_size):
2603-
# padding
2616+
# padding & padded dimensions
26042617
pad = mutation_size * self.pad
2605-
# width and height with padding added.
2606-
width, height = width + 2 * pad, height + 2 * pad
2607-
# boundary of the padded box
2618+
dx, dy = width + 2 * pad, height + 2 * pad
26082619
x0, y0 = x0 - pad, y0 - pad,
2609-
x1, y1 = x0 + width, y0 + height
2610-
2611-
dx = (y1 - y0) / 2
2612-
dxx = dx / 2
2613-
x0 = x0 + pad / 1.4 # adjust by ~sqrt(2)
2614-
2615-
return Path._create_closed(
2616-
[(x0 + dxx, y0), (x1, y0), (x1, y1), (x0 + dxx, y1),
2617-
(x0 + dxx, y1 + dxx), (x0 - dx, y0 + dx),
2618-
(x0 + dxx, y0 - dxx), # arrow
2619-
(x0 + dxx, y0)])
2620+
x1, y1 = x0 + dx, y0 + dy
2621+
2622+
head_dy = self.head_width * dy
2623+
mid_y = (y0 + y1) / 2
2624+
shaft_y0 = mid_y - head_dy / 2
2625+
shaft_y1 = mid_y + head_dy / 2
2626+
2627+
cot = 1 / math.tan(math.radians(self.head_angle / 2))
2628+
2629+
if cot > 0:
2630+
# tip_x is chosen s.t. the angled line moving back from the tip hits
2631+
# i) if head_width > 1: the box corner, or ii) if head_width <
2632+
# 1 the box edge at the point giving the correct shaft width.
2633+
tip_x = x1 + cot * min(dy, head_dy) / 2
2634+
shaft_x = tip_x - cot * head_dy / 2
2635+
return Path._create_closed([
2636+
(x0, y0), (shaft_x, y0), (shaft_x, shaft_y0),
2637+
(tip_x, mid_y),
2638+
(shaft_x, shaft_y1), (shaft_x, y1), (x0, y1),
2639+
])
2640+
else: # Reverse arrowhead.
2641+
# Make the long (outer) side of the arrowhead flush with the
2642+
# original box, and move back accordingly (but clipped to no
2643+
# more than the box length). If this clipping is necessary,
2644+
# the y positions at the short (inner) side of the arrowhead
2645+
# will be thicker than the original box, hence the need to
2646+
# recompute mid_y0 & mid_y1.
2647+
# If head_width < 1 no arrowhead is drawn.
2648+
dx = min(-cot * max(head_dy - dy, 0) / 2, dx) # cot < 0!
2649+
mid_y0 = min(shaft_y0, y0) - dx / cot
2650+
mid_y1 = max(shaft_y1, y1) + dx / cot
2651+
return Path._create_closed([
2652+
(x0, y0), (x1 - dx, mid_y0), (x1, shaft_y0),
2653+
(x1, shaft_y1), (x1 - dx, mid_y1), (x0, y1),
2654+
])
26202655

26212656
@_register_style(_style_list)
2622-
class RArrow(LArrow):
2623-
"""A box in the shape of a right-pointing arrow."""
2657+
class LArrow(RArrow):
2658+
"""A box in the shape of a left-pointing arrow."""
26242659

26252660
def __call__(self, x0, y0, width, height, mutation_size):
2626-
p = BoxStyle.LArrow.__call__(
2627-
self, x0, y0, width, height, mutation_size)
2661+
p = super().__call__(x0, y0, width, height, mutation_size)
26282662
p.vertices[:, 0] = 2 * x0 + width - p.vertices[:, 0]
26292663
return p
26302664

26312665
@_register_style(_style_list)
2632-
class DArrow:
2666+
class DArrow(RArrow):
26332667
"""A box in the shape of a two-way arrow."""
2634-
# Modified from LArrow to add a right arrow to the bbox.
2635-
2636-
def __init__(self, pad=0.3):
2637-
"""
2638-
Parameters
2639-
----------
2640-
pad : float, default: 0.3
2641-
The amount of padding around the original box.
2642-
"""
2643-
self.pad = pad
2668+
# Modified from RArrow to have arrows on both sides; see comments above.
26442669

26452670
def __call__(self, x0, y0, width, height, mutation_size):
2646-
# padding
2671+
# padding & padded dimensions
26472672
pad = mutation_size * self.pad
2648-
# width and height with padding added.
2649-
# The width is padded by the arrows, so we don't need to pad it.
2650-
height = height + 2 * pad
2651-
# boundary of the padded box
2652-
x0, y0 = x0 - pad, y0 - pad
2653-
x1, y1 = x0 + width, y0 + height
2654-
2655-
dx = (y1 - y0) / 2
2656-
dxx = dx / 2
2657-
x0 = x0 + pad / 1.4 # adjust by ~sqrt(2)
2658-
2659-
return Path._create_closed([
2660-
(x0 + dxx, y0), (x1, y0), # bot-segment
2661-
(x1, y0 - dxx), (x1 + dx + dxx, y0 + dx),
2662-
(x1, y1 + dxx), # right-arrow
2663-
(x1, y1), (x0 + dxx, y1), # top-segment
2664-
(x0 + dxx, y1 + dxx), (x0 - dx, y0 + dx),
2665-
(x0 + dxx, y0 - dxx), # left-arrow
2666-
(x0 + dxx, y0)])
2673+
dx, dy = width + 2 * pad, height + 2 * pad
2674+
x0, y0 = x0 - pad, y0 - pad,
2675+
x1, y1 = x0 + dx, y0 + dy
2676+
2677+
head_dy = self.head_width * dy
2678+
mid_y = (y0 + y1) / 2
2679+
shaft_y0 = mid_y - head_dy / 2
2680+
shaft_y1 = mid_y + head_dy / 2
2681+
2682+
cot = 1 / math.tan(math.radians(self.head_angle / 2))
2683+
2684+
if cot > 0:
2685+
tip_x0 = x0 - cot * min(dy, head_dy) / 2
2686+
shaft_x0 = tip_x0 + cot * head_dy / 2
2687+
tip_x1 = x1 + cot * min(dy, head_dy) / 2
2688+
shaft_x1 = tip_x1 - cot * head_dy / 2
2689+
return Path._create_closed([
2690+
(shaft_x0, y1), (shaft_x0, shaft_y1),
2691+
(tip_x0, mid_y),
2692+
(shaft_x0, shaft_y0), (shaft_x0, y0),
2693+
(shaft_x1, y0), (shaft_x1, shaft_y0),
2694+
(tip_x1, mid_y),
2695+
(shaft_x1, shaft_y1), (shaft_x1, y1),
2696+
])
2697+
else:
2698+
# Don't move back by more than half the box length.
2699+
dx = min(-cot * max(head_dy - dy, 0) / 2, dx / 2) # cot < 0!
2700+
mid_y0 = min(shaft_y0, y0) - dx / cot
2701+
mid_y1 = max(shaft_y1, y1) + dx / cot
2702+
return Path._create_closed([
2703+
(x0, shaft_y0), (x0 + dx, mid_y0),
2704+
(x1 - dx, mid_y0), (x1, shaft_y0),
2705+
(x1, shaft_y1), (x1 - dx, mid_y1),
2706+
(x0 + dx, mid_y1), (x0, shaft_y1),
2707+
])
26672708

26682709
@_register_style(_style_list)
26692710
class Round:

lib/matplotlib/patches.pyi

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -379,9 +379,13 @@ class BoxStyle(_Style):
379379
mutation_size: float,
380380
) -> Path: ...
381381

382-
class LArrow(BoxStyle):
382+
class RArrow(BoxStyle):
383383
pad: float
384-
def __init__(self, pad: float = ...) -> None: ...
384+
head_width: float
385+
head_angle: float
386+
def __init__(
387+
self, pad: float = ..., head_width: float = ..., head_angle: float = ...
388+
) -> None: ...
385389
def __call__(
386390
self,
387391
x0: float,
@@ -391,7 +395,7 @@ class BoxStyle(_Style):
391395
mutation_size: float,
392396
) -> Path: ...
393397

394-
class RArrow(LArrow):
398+
class LArrow(RArrow):
395399
def __call__(
396400
self,
397401
x0: float,
@@ -401,9 +405,13 @@ class BoxStyle(_Style):
401405
mutation_size: float,
402406
) -> Path: ...
403407

404-
class DArrow(BoxStyle):
408+
class DArrow(RArrow):
405409
pad: float
406-
def __init__(self, pad: float = ...) -> None: ...
410+
head_width: float
411+
head_angle: float
412+
def __init__(
413+
self, pad: float = ..., head_width: float = ..., head_angle: float = ...
414+
) -> None: ...
407415
def __call__(
408416
self,
409417
x0: float,
47.3 KB
Loading
-7.35 KB
Loading

lib/matplotlib/tests/test_arrow_patches.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,48 @@ def test_boxarrow():
4949
bbox=dict(boxstyle=stylename, fc="w", ec="k"))
5050

5151

52+
@image_comparison(['boxarrow_adjustment_test_image.png'], style='mpl20')
53+
def test_boxarrow_adjustment():
54+
55+
styles = ['larrow', 'rarrow', 'darrow']
56+
57+
# Cases [head_width, head_angle] to test for each arrow style
58+
cases = [
59+
[1.5, 90],
60+
[1.5, 170], # Test dynamic padding
61+
[0.75, 30],
62+
[0.5, -10], # Should just give a rectangle
63+
[2, -90],
64+
[2, -15] # None of arrow body is outside of head
65+
]
66+
67+
# Horizontal and vertical spacings of arrow centres
68+
spacing_horizontal = 3.75
69+
spacing_vertical = 1.6
70+
71+
# Numbers of styles and cases
72+
m = len(styles)
73+
n = len(cases)
74+
75+
figwidth = (m * spacing_horizontal)
76+
figheight = (n * spacing_vertical) + .5
77+
78+
fig = plt.figure(figsize=(figwidth / 1.5, figheight / 1.5))
79+
80+
fontsize = 0.3 * 72
81+
82+
for i, stylename in enumerate(styles):
83+
for j, case in enumerate(cases):
84+
# Draw arrow
85+
fig.text(
86+
((m - i) * spacing_horizontal - 1.5) / figwidth,
87+
((n - j) * spacing_vertical - 0.5) / figheight,
88+
stylename, ha='center', va='center',
89+
size=fontsize, transform=fig.transFigure,
90+
bbox=dict(boxstyle=f"{stylename}, head_width={case[0]}, \
91+
head_angle={case[1]}", fc="w", ec="k"))
92+
93+
5294
def __prepare_fancyarrow_dpi_cor_test():
5395
"""
5496
Convenience function that prepares and returns a FancyArrowPatch. It aims

0 commit comments

Comments
 (0)