GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Implement head resizing (and reversal) for larrow/rarrow/darrow by CharlieThornton33 · Pull Request #29998 · matplotlib/matplotlib

Conversation

@CharlieThornton33

@CharlieThornton33 CharlieThornton33 commented May 1, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Contributor

PR summary

When creating an annotation using boxed text in an arrow shape (patches.BoxStyle.LArrow/RArrow/DArrow), head_width and head_angle arguments may now be passed to adjust the size of the arrow head(s), and (via the use of negative angles) generate 'reversed' arrow heads. Addresses #24618 and is a direct continuation of @Abitamim's work in #24744.

This solves both the issue initially mentioned in #24618 (creating a pentagonal 'road-sign' annotation box), as well as allowing for many other customisation options for arrows; for example, a reversed-head DArrow could act as a way to bring attention to a certain region on an axis.

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(4, 3))

t1 = ax.text(0.5, 0.75, "Road-sign",
            ha="center", va="center", size=15,
            bbox=dict(boxstyle="rarrow,pad=0.3,head_width=1,head_angle=45",
                      fc="lightblue", ec="steelblue", lw=2))

t2 = ax.text(0.5, 0.25, "  Reversed heads   ",
            ha="center", va="center", size=15,
            bbox=dict(boxstyle="darrow,pad=0.3,head_width=1.5,head_angle=-45",
                      fc="lightblue", ec="steelblue", lw=2))

plt.show()

pr_example

A new test, test_boxarrow_adjustment, has been added to test these options.

PR checklist

  • "closes [ENH]: "Road sign" boxstyle/annotation #24618" is in the body of the PR description to link the related issue
  • new and changed code is tested
  • Plotting related features are demonstrated in an example
  • New Features and API Changes are noted with a directive and release note
  • Documentation complies with general and docstring guidelines

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Abitamim and others added 14 commits April 16, 2025 21:20
@Abitamim @CharlieThornton33
Make arrow annotation arrowhead adjustable
2f366fa
@Abitamim @CharlieThornton33
Add test - not sure if correct
e8d7eb7
@Abitamim @CharlieThornton33
Update tutorial
15bd7dc
@Abitamim @CharlieThornton33
Fixed linting
4f57495
@CharlieThornton33
Add reversed arrow-head support to larrow/rarrow
5c3fa60
@CharlieThornton33
Fix reversed arrow-head support for single arrows, handle edge cases
8065549
@CharlieThornton33
Add support for reversed arrow-heads and edge cases in DArrow, update…
0471e92
… type hinting for LArrow/RArrow/DArrow
@CharlieThornton33
Update docstings, add what's new notes
212cbc9
@CharlieThornton33
Fix typo in DArrow drawing code
273a2fe
@CharlieThornton33
Fixed padding inconsistency: reverted breaking API change
bf2d06b
@CharlieThornton33
Fix what's new example
0ea2f5a
@CharlieThornton33
Create unit test for boxarrow head adjustment
d7fd405
@CharlieThornton33
Fixed linting
ff34b4b
@CharlieThornton33
Update new test to use mpl20 figure style
d9b0b32
github-actions[bot]
github-actions Bot reviewed May 1, 2025
View reviewed changes

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Thank you for opening your first PR into Matplotlib!

If you have not heard from us in a week or so, please leave a new comment below and that should bring it to our attention. Most of our reviewers are volunteers and sometimes things fall through the cracks.

You can also join us on gitter for real-time discussion.

For details on testing, writing docs, and our review process, please see the developer guide

We strive to be a welcoming and open project. Please follow our Code of Conduct.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@CharlieThornton33

CharlieThornton33 commented May 9, 2025

Copy link
Copy Markdown
Contributor Author

Hi everyone,

I was just wondering whether someone would be willing to have a look at this PR? I feel like it's in a complete or nearly-complete state but need some guidance - the two failing CI jobs seem to be:

  • PR cleanliness:
    The added unit test was written first by @Abitamim, and then expanded by myself. When I expanded the test to check the newly-added reversed arrow heads, I changed both the test name (temp_test_boxarrow → test_boxarrow_adjustment) and the name of the output image (roadsign_test_image.png → boxarrow_adjustment_test_image.png) to reflect this. The 'file both added and deleted' error given seems to be a result of adding then deleting roadsign_test_image.png.
  • Python 3.11 on macos-13:
    The failing test here (test_blitting_events) is marked as flaky, with the comment # subprocesses can struggle to get the display, so rerun a few times. I feel it may be that this was just a random instance of the test failing multiple times in a row; unfortunately I don't have access to macos-supported hardware for rerunning this locally, so I was wondering if someone would be able to take a look.

Any help would be greatly appreciated. Thanks!

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@timhoffm

timhoffm commented May 9, 2025

Copy link
Copy Markdown
Member

PR-cleanliness: This is a check so that we don't merge commits with unneeded images, which would increase repo size. You can either squash and force-push. Or ignore this and we'll squash-merge at the end.

Ignore the macos test failure. It's unrelated.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@CharlieThornton33

CharlieThornton33 commented May 9, 2025

Copy link
Copy Markdown
Contributor Author

Thank you for getting back so quickly! Would it be okay if you handled the squashing - I'm relatively new to git, and I'm not sure of the best method to use for squashing to keep a relatively clean but traceable commit history?

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

timhoffm
timhoffm reviewed May 9, 2025
View reviewed changes

@timhoffm timhoffm left a comment

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Looking at the arrows in the test image, https://github.com/matplotlib/matplotlib/pull/29998/files#diff-ff8adb592a399d60ee5c93fbff7552182f53b74be4573ca21db404ef3c3a6673, the outline sometimes cuts through the text, which must not happen.

From plotting

plt.text(0.5, 0.5, 'text', size=24, bbox=dict(boxstyle="rarrow", fc="w", ec="k"),)
plt.text(0.5, 0.5, 'text', size=24, bbox=dict(boxstyle="square", pad=0, fc="C0", ec='none'))

we can see that the original calculation had a shaft with the length of the text, i.e. the arrow head starts at -pad from the end of the text.

image

This setting was ok for the hard-coded parameters, but when head_size and head_angle can vary, we have to invest more into a good positioning of arrow head.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread lib/matplotlib/tests/test_arrow_patches.py Resolved

Uh oh!

There was an error while loading. Please reload this page.

@CharlieThornton33

CharlieThornton33 commented May 10, 2025

Copy link
Copy Markdown
Contributor Author

Since I'm going to need to make some relatively major changes to the contents of this PR (a redo of the arrow drawing code and a rewrite of its unit test), would it be better for me to amend my last commit (d9b0b32) as suggested in the contributing guide, or make a few new commits? I'm not quite sure how to add more commits to an open PR - do I just push the changes to my development branch?

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@rcomer

rcomer commented May 10, 2025

Copy link
Copy Markdown
Member

@CharlieThornton33 yes you can just push more commits to your current branch and they will appear in this PR. Since you already have several commits they will ultimately need squashing one way or the other (and I see you already asked that we handle that), so I do not think you gain anything from the —amend option.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@CharlieThornton33
Changed padding for LArrow/RArrow to prevent text spilling out
f3369c0
@CharlieThornton33

CharlieThornton33 commented May 14, 2025

Copy link
Copy Markdown
Contributor Author

I've just redone the padding (only for LArrow and RArrow for the moment), and I think the issue with the text escaping the outline has been fixed. I set the arrow body to dynamically increase in length to make sure there's always a gap of at least pad between the edge of the text closest to the arrow head and the position where that edge would first intercept the outline if moved towards the tip:

import numpy as np
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(3, 8))

t0 = ax.text(0.5, 0.9, "Text",
            ha="center", va="center", rotation=0, size=30,
            bbox=dict(boxstyle="rarrow,pad=0.3,head_width=1.5,head_angle=-90",
                      fc="lightblue", ec="steelblue", lw=2))

t1 = ax.text(0.5, 0.7, "Text",
            ha="center", va="center", rotation=0, size=30,
            bbox=dict(boxstyle="rarrow,pad=0.3,head_width=1.5,head_angle=70",
                      fc="lightblue", ec="steelblue", lw=2))

t2 = ax.text(0.5, 0.5, "Text",
            ha="center", va="center", rotation=0, size=30,
            bbox=dict(boxstyle="rarrow,pad=0.3,head_width=1.5,head_angle=100",
                      fc="lightblue", ec="steelblue", lw=2))

t3 = ax.text(0.5, 0.3, "Text",
            ha="center", va="center", rotation=0, size=30,
            bbox=dict(boxstyle="rarrow,pad=0.3,head_width=1.5,head_angle=130",
                      fc="lightblue", ec="steelblue", lw=2))

t4 = ax.text(0.5, 0.1, "Text",
            ha="center", va="center", rotation=0, size=30,
            bbox=dict(boxstyle="rarrow,pad=0.3,head_width=1.5,head_angle=170",
                      fc="lightblue", ec="steelblue", lw=2))

plt.show()

new_padding

It's also a bit concerning that the CI workflow seems to run on every commit to this PR - am I okay to disable tests in my commit messages until the one where I write the new test to save on CI resources?

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@CharlieThornton33

CharlieThornton33 commented May 14, 2025

Copy link
Copy Markdown
Contributor Author

I've just noticed - all of the pytest test runs are about to fail because I forgot to remove the flawed test_boxarrow_adjustment test. Is there any way to cancel a CI test run?

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@rcomer

rcomer commented May 14, 2025

Copy link
Copy Markdown
Member

@CharlieThornton33 I wouldn't worry about the CI failing, but if they are still running when you push another commit they will be interrupted and re-started.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@CharlieThornton33
[skip ci] Fix spelling mistake in LArrow padding code
3913ef6
@melissawm melissawm added this to First Time Contributors May 22, 2025
@melissawm melissawm added topic: annotation New feature labels May 22, 2025
@melissawm melissawm moved this to Needs review in First Time Contributors May 22, 2025
timhoffm
timhoffm reviewed Sep 9, 2025
View reviewed changes
Comment thread doc/users/next_whats_new/box_arrow_size_controls.rst Resolved

Uh oh!

There was an error while loading. Please reload this page.

@CharlieThornton33
[ci doc] Move what's new page to releases directory
7b4b3b1
@github-actions github-actions Bot added the Documentation: user guide files in galleries/users_explain or doc/users label Sep 9, 2025
timhoffm
timhoffm reviewed Sep 10, 2025
View reviewed changes
Comment thread lib/matplotlib/patches.py Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread lib/matplotlib/patches.py Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread lib/matplotlib/patches.py Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread lib/matplotlib/patches.py Resolved

Uh oh!

There was an error while loading. Please reload this page.

Comment thread lib/matplotlib/patches.py Resolved

Uh oh!

There was an error while loading. Please reload this page.

CharlieThornton33 added 2 commits September 11, 2025 21:22
@CharlieThornton33
[skip ci] Make head-width validation clearer
119a051
@CharlieThornton33
Move head-angle validation into constructor
80d1c40
timhoffm
timhoffm reviewed Sep 11, 2025
View reviewed changes
Comment thread lib/matplotlib/patches.py Resolved

Uh oh!

There was an error while loading. Please reload this page.

CharlieThornton33 added 3 commits September 11, 2025 21:39
@CharlieThornton33
Remove references to 'text' for describing original box
460306e
@CharlieThornton33
[skip ci] Fix accidental paste into docstring
201e62c
@CharlieThornton33
Fix nomenclature (arrow tail -> arrow shaft)
94fa82d
@CharlieThornton33

CharlieThornton33 commented Sep 11, 2025

Copy link
Copy Markdown
Contributor Author

I'm a bit confused about how to test one of @QuLogic's review notes:
https://github.com/matplotlib/matplotlib/pull/29998/files/2b83affd050cf92231756104a6abc7266d2e0565#r2191691643

How would it be possible to test modification of a public attribute? Would it just be initialising the box with fig.text, then modifying the attributes (I don't know if setter/getter methods exist for these; I'll check now) before the test ends and presumably calls __call__ to render the bbox?

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@timhoffm

timhoffm commented Sep 12, 2025

Copy link
Copy Markdown
Member

I believe @QuLogic's comment is fuelled by the idea: What if somebody created a boxstyle class and then modified it

arrowstyle = LArrow(head_angle=45)
plt.text(0, 0, "text", bbox={'boxstyle': arrowstyle}
arrowstyle.head_angle=30
plt.show()

Since the class stores all parameters in attributes as is and all of the logic is in __call__, that will work, even though it should be a very rare use case. I would not bother to add a test for this. One caveat: The constructor does the range checking, so that you could set the attributes to invalid values. Overall, I'd tolerate that. The alternatives would be to do the validation in __call__ instead of __init__, which is a less good user experince as the error happens further away from the offending code. Or you would have to guard the attributes via a property setter, but I think it's not worth the added complexity. The worst that will happen is that for the very very rare case that users modify an existing BoxStyle and use an invalid value, that will blow up later with a more cryptic error message.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

timhoffm
timhoffm reviewed Sep 12, 2025
View reviewed changes
Comment thread lib/matplotlib/patches.py
Comment on lines +2550 to +2552
# Pad to position original box and its margins exactly inside arrow shaft
padding_offset = (0.5 * pad) + (0.25 * mutation_size)
x0 -= padding_offset

@timhoffm timhoffm Sep 12, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

Suggested change
# Pad to position original box and its margins exactly inside arrow shaft
padding_offset = (0.5 * pad) + (0.25 * mutation_size)
x0 -= padding_offset
# Pad to position original box and its margins exactly inside arrow shaft
padding_offset = (0.5 * pad) + (0.25 * mutation_size)
x0 -= padding_offset

I don't understand this. I thought the original box starts at $x_0$, then we've done $x'_0 = x_0 - pad$ as the start of the padded box. What is this additional $x''_0 = x'_0 - 0.5 \mathrm{pad} - 0.25 \mathrm{mutation_size}$?

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@CharlieThornton33 CharlieThornton33 Sep 12, 2025

Copy link
Copy Markdown
Contributor Author

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

I'm not entirely sure myself why this is needed. When writing the code for padding, I noticed that padding just via $x_{0}' = x_{0} - \texttt{pad}$ gave clearly wrong results, so I used print(<text object>.get_bbox_patch().get_path()) to look numerically at the positioning of the drawn vertices. From this, I found that there was a discrepancy present that depended only on self.pad linearly, then used linear regression to get this empirical expression for padding_offset.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions

@QuLogic QuLogic Sep 23, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

On main, without setting head_length, etc, the example from the What's new note looks like:
Figure_1
whereas here, it looks like:
Figure_2
and if we revert the extra padding_offset, it goes closer to before:
Figure_3
but it's still not a perfect match:
diff

So I don't quite understand the math added here, and I guess it depends on how close we want it to match the old version. Currently, it doesn't at all, but without padding_offset, it's still not exactly the same either.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@CharlieThornton33

CharlieThornton33 commented Sep 19, 2025

Copy link
Copy Markdown
Contributor Author

Hi,

I was wondering how I should proceed with this PR - I can't think of anything else that needs finishing for it.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@timhoffm

timhoffm commented Sep 20, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Member

The result looks good. Though, I would like to understand the calculation #29998 (comment). Relying on empirical values that we don’t understand poses a danger that they are incorrect for some cases.

We need to debug the logic step by step and either extract the coordinates to draw the picture by hand, or draw points/rectangles (see e.g. #29833 (comment)) to understand what’s going on.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

QuLogic
QuLogic reviewed Sep 23, 2025
View reviewed changes
Comment thread doc/release/next_whats_new/box_arrow_size_controls.rst

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

@QuLogic QuLogic Sep 23, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

You should be able to wrap this as long as you wrap the first line.

    :alt:
       Six arrow-shaped text boxes, ...
       more text ...

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
Comment thread lib/matplotlib/patches.py
Comment on lines +2550 to +2552
# Pad to position original box and its margins exactly inside arrow shaft
padding_offset = (0.5 * pad) + (0.25 * mutation_size)
x0 -= padding_offset

@QuLogic QuLogic Sep 23, 2025

Copy link
Copy Markdown
Member

There was a problem hiding this comment.

Choose a reason for hiding this comment

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

On main, without setting head_length, etc, the example from the What's new note looks like:
Figure_1
whereas here, it looks like:
Figure_2
and if we revert the extra padding_offset, it goes closer to before:
Figure_3
but it's still not a perfect match:
diff

So I don't quite understand the math added here, and I guess it depends on how close we want it to match the old version. Currently, it doesn't at all, but without padding_offset, it's still not exactly the same either.

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@timhoffm

timhoffm commented Sep 23, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Member

I guess it depends on how close we want it to match the old version.

my main concern is consistency. The new code must produce reasonable results, I.e. text must be inside the arrow outline, for all parameter combinations. The code should be not too complex (try to avoid too much special-casing). Matching existing behavior is only third - note that these are outlines around a text, and are defined via the text position. The tip position and arrow size are not explicitly set but depend on font metrics and size. I therefore think it is permissible to not exactly reproduce existing behavior.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@QuLogic

QuLogic commented Sep 23, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Member

Going through the whole range, it does appear to be a bit jumpy?

At about 100 degrees, the padding changes:
angle

At about 2.5 width, the padding changes:
width

Source code
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from matplotlib.widgets import Slider

fig, ax = plt.subplots(3, 1, height_ratios=[4, 1, 1])

texts = []
for i, arrow in enumerate(['larrow', 'rarrow', 'darrow']):
    texts.append(
        ax[0].text(0.5, 0.3*i + 0.2, 'Arrow', ha='center', size=16,
                   bbox=dict(boxstyle=f"{arrow}, pad=0.3, head_angle=150")))


def update_head_angle(value):
    for t in texts:
        t.get_bbox_patch().get_boxstyle().head_angle = value
    fig.canvas.draw_idle()


def update_head_width(value):
    for t in texts:
        t.get_bbox_patch().get_boxstyle().head_width = value
    fig.canvas.draw_idle


angle_slider = Slider(ax[1], 'Angle', 0, 360, valinit=150, closedmin=False)
angle_slider.on_changed(update_head_angle)

width_slider = Slider(ax[2], 'Width', 0, 3, valinit=1.5)
width_slider.on_changed(update_head_width)

anim = FuncAnimation(fig, lambda t: angle_slider.set_val(t),
                     frames=range(1, 360), interval=3000/360)
anim.save('angle.gif')

angle_slider.set_val(150)

anim = FuncAnimation(fig, lambda t: width_slider.set_val(t/100),
                     frames=range(0, 300), interval=3000/300)
anim.save('width.gif')

# plt.show()
All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@timhoffm

timhoffm commented Sep 23, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Member

The discontinuity comes from the fact that for small arrows, we need a margin
image
whereas for reasonably large arrows we left it out:
image

The old behavior shifted the text into the arrow (equivalent to a negative margin), but that does not work for various combinations of arrow parameters and detecting that in detail would be much more complex.

Alternatives to the jump would be

  1. always add the margin - that felt a bit too much to me but YMMV, and it is even further from the old behavior
image
  1. Make the margin dependent on arrow parameters at the cost of more complex logic.
All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@tacaswell

tacaswell commented Sep 25, 2025

Copy link
Copy Markdown
Member

Discussed an the call. Keeping consistency on these is not super important, no one should be extracting quantitative information from these arrows.

  • simplest option (Tim's 1): go to fixed padding
    • pro: dead simple
    • con: not as aesthetic
  • smart algorithm (Tim's 2): proposed by @anntzer to adjust the tail such that the arrow just touches the text bounding box
    • pro: might look a bit better
    • con: more complicated, has some odd behavior at extreme aspect ratios and/or big arrows that will have to be thought bout
  • just accept the jump
    • pro: it is done! We do not think anyone will actually make the slider Elliott made above and as noted no one should be
    • con: the jump feels wrong, having two magic numbers (the padding value and the change over thresholds) is not great.

Personally I think the smart algorithm will probably look better, but fixed pad is my second choice.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@timhoffm

timhoffm commented Sep 26, 2025

Copy link
Copy Markdown
Member

Just as a reminder, I've drawn all(?) the options in #29998 (comment), #29998 (comment).

The bounding-box options (4), (5) are generally not as nice as thought; or at least not better than the simple ones (1), (2). I believe if we want to be smart, we must be even smarter - possibly a combination of cases or some more advanced distance metric for tip-to-text.

Would it be an option to mark the behavior as provisional and allow us the freedom to later change the head positioning algorithm? - Only slight caveat is that we change the behavior for existing users now (one-time break is justified to enable generalization), but we would possibly break them again and leave them in a limbo state in between. Feature flags (#30519) would help with these, but we'd still have to implement them soon.

Overall, possibly the sane decision is to defer this topic to after the 3.11 release.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@jklymak

jklymak commented Sep 26, 2025 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

Copy link
Copy Markdown
Member

As a meta comment - if the goal is "to bring attention to a certain region on the axes" this doesn't seem like the right tool. I'd make a precisely placed arrow and add text over top and adjust the text to fit the aesthetics of the arrow. Trying to reverse engineer that behaviour into something precisely depending on a text bounding box doesn't seem like a good API.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@timhoffm

timhoffm commented Sep 26, 2025

Copy link
Copy Markdown
Member

That is true and the reason why we can do a breaking change in size/positioning.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@anntzer

anntzer commented Feb 12, 2026

Copy link
Copy Markdown
Contributor

The following patch implements the "tangent shaft" rules suggested above. The resulting shafts vary continuously with head width and angle; the only somewhat awkward case is the one of an arrow shaft longer than the box itself (at the beginning of the varying angle example); it could easily be changed to be drawn as a "full triangle" instead if we decide to do so.

the patch
diff --git i/lib/matplotlib/patches.py w/lib/matplotlib/patches.py
index 0c8d6b5fee..e7b3b77362 100644
--- i/lib/matplotlib/patches.py
+++ w/lib/matplotlib/patches.py
@@ -2526,7 +2526,7 @@ class BoxStyle(_Style):
     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):
             """
             Parameters
             ----------
@@ -2534,25 +2534,46 @@ class BoxStyle(_Style):
                 The amount of padding around the original box.
             """
             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
+            x1, y1 = x0 + dx, y0 + dy
 
-            dx = (y1 - y0) / 2
-            dxx = dx / 2
-            x0 = x0 + pad / 1.4  # adjust by ~sqrt(2)
+            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
 
-            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)])
+            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:
+                dx = -cot * (head_dy - dy) / 2  # cot < 0!
+                return Path._create_closed([
+                    (x0 + min(dx, 0), y1), (x0 - max(dx, 0), shaft_y1),
+                    (x0 - max(dx, 0), shaft_y0), (x0 + min(dx, 0), y0),
+                    (x1 - min(dx, 0), y0), (x1 + max(dx, 0), shaft_y0),
+                    (x1 + max(dx, 0), shaft_y1), (x1 - min(dx, 0), y1),
+                ])
 
     @_register_style(_style_list)
     class RArrow(LArrow):
@@ -2565,41 +2586,45 @@ class BoxStyle(_Style):
             return p
 
     @_register_style(_style_list)
-    class DArrow:
+    class DArrow(LArrow):
         """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 LArrow to add a right arrow to the bbox; 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, dy = width + 2 * pad, height + 2 * pad
+            x0, y0 = x0 - pad, y0 - pad,
+            x1, y1 = x0 + dx, y0 + dy
 
-            dx = (y1 - y0) / 2
-            dxx = dx / 2
-            x0 = x0 + pad / 1.4  # adjust by ~sqrt(2)
+            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
 
-            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)])
+            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:
+                dx = -cot * (head_dy - dy) / 2  # cot < 0!
+                return Path._create_closed([
+                    (x0, y0),
+                    (x1 - min(dx, 0), y0), (x1 + max(dx, 0), shaft_y0),
+                    (x1 + max(dx, 0), shaft_y1), (x1 - min(dx, 0), y1),
+                    (x0, y1),
+                ])
 
     @_register_style(_style_list)
     class Round:

angle
width

This needs to be rebased appropriately to give credit to the earlier authors (e.g. by committing their tests and docs after proper editing); as usual I'll just post the patch here for now, feel free to pick it up and turn it into a PR if I don't do that first.

timhoffm reacted with thumbs up emoji
All reactions
  • 👍 1 reaction

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@anntzer anntzer mentioned this pull request Feb 25, 2026
Allow tuning the shape of {L,R,D}Arrow tips. #31198
Merged
5 tasks
@timhoffm

timhoffm commented Mar 26, 2026

Copy link
Copy Markdown
Member

This is superseeded by #31198. @CharlieThornton33 thanks for your detailed work, it's included (with attribution) in the follow up.

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@timhoffm timhoffm closed this Mar 26, 2026
@github-project-automation github-project-automation Bot moved this from Waiting for author to Done in First Time Contributors Mar 26, 2026
@QuLogic QuLogic added the status: superseded label Mar 26, 2026
@QuLogic QuLogic added this to the v3.11.0 milestone Mar 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@QuLogic QuLogic QuLogic left review comments
@timhoffm timhoffm timhoffm left review comments
@github-actions github-actions[bot] github-actions[bot] left review comments

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

Documentation: user guide files in galleries/users_explain or doc/users New feature status: superseded topic: annotation

Projects

First Time Contributors
Status: Done

Milestone

v3.11.0

Development

Successfully merging this pull request may close these issues.

[ENH]: "Road sign" boxstyle/annotation

Uh oh!

There was an error while loading. Please reload this page.

9 participants

@CharlieThornton33 @timhoffm @rcomer @QuLogic @tacaswell @jklymak @anntzer @melissawm @Abitamim
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.