X Tutup
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions doc/release/next_whats_new/box_arrow_size_controls.rst
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Member

@timhoffm timhoffm Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

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()
12 changes: 6 additions & 6 deletions galleries/users_explain/text/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 94 additions & 53 deletions lib/matplotlib/patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 13 additions & 5 deletions lib/matplotlib/patches.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -391,7 +395,7 @@ class BoxStyle(_Style):
mutation_size: float,
) -> Path: ...

class RArrow(LArrow):
class LArrow(RArrow):
def __call__(
self,
x0: float,
Expand All @@ -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,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions lib/matplotlib/tests/test_arrow_patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
X Tutup