Splitting the tail from the arrow and controlling the curve via points yields better results.
Matplotlib's FancyArrowPatch implementation is interface-oriented rather than coordinate-based (as seen in the source code, it selects rendering positions for quadratic Bézier curve drawing). By imitating its approach, quadratic Bézier curves can be drawn at the coordinate level.
For the arrowhead, I referenced the approach from https://github.com/matplotlib/matplotlib/issues/17284, which offers greater control. I selected the -|>
arrow style over Simple
because it provides better visual results when line widths are small.
My blog post discusses this topic in greater detail: https://omnisyr.github.io/post/%3B%3B%3BeNon-Solid%20Curves%20with%20Arrows%20in%20Matplotlib%3B%3B%3Be%3B%3B%3BcMatplotlib-zhong-dai-jian-tou-de-fei-shi-qu-xian-%3B%3B%3Bc.html
import math
import matplotlib.patches as patches
import matplotlib.pyplot as plt
import matplotlib.transforms as transforms
import numpy as np
def add_arrow(from_, to_, rad=0.3, control_=None, color='#515151',
line='--', head_length=0.6, size=0.04, detail=False):
"""
:param from_:Starting point
:param to_:Target endpoint
:param rad:Curve radius
:param control_:Control points;
if None, calculated based on the curvature of the curve.
:param color:Drawing colors
:param line:Curve Style
:param head_length:Arrow size
:param size:Mask Size
:param detail:Select whether to draw details,
and manually adjust the mask size by drawing details.
"""
if control_ is None:
x1, y1 = np.array(from_)
x2, y2 = np.array(to_)
dx, dy = x2 - x1, y2 - y1
x12, y12 = (x1 + x2) / 2., (y1 + y2) / 2.
cx, cy = x12 + rad * dy, y12 - rad * dx
control_ = (cx, cy)
vertices = [from_, control_, to_]
codes = [patches.Path.MOVETO, patches.Path.CURVE3, patches.Path.CURVE3]
path = patches.Path(vertices, codes)
patch = patches.PathPatch(path, facecolor='none', edgecolor=color,
linestyle=line, linewidth=1, zorder=-2)
ax.add_patch(patch)
direction = -1 if control_[0] < to_[0] else 1
mask_c = 'red' if detail else 'white'
mask = patches.FancyBboxPatch(
(to_[0], to_[1] - size / 2), direction * size, size, boxstyle="square, pad=0",
ec=mask_c, fc=mask_c, zorder=-1, linewidth=1)
de = math.degrees(math.atan((control_[1] - to_[1]) / (control_[0] - to_[0])))
tf = transforms.Affine2D().rotate_deg_around(to_[0], to_[1], de) + ax.transData
mask.set_transform(tf)
ax.add_patch(mask)
ax.annotate("", to_, xytext=control_, arrowprops=dict(
linewidth=0,
arrowstyle="-|>, head_width=%f, head_length=%f" % (head_length / 2, head_length),
shrinkA=0, shrinkB=0, facecolor=color, linestyle="solid", mutation_scale=10
))
if not detail:
return
ax.scatter(control_[0], control_[1], c=color, marker='x', s=12, linewidths=0.8)
ax.scatter(from_[0], from_[1], c=color, marker='x', s=12, linewidths=0.8)
ax.scatter(to_[0], to_[1], c=color, marker='x', s=12, linewidths=0.8)
ax.plot((control_[0], from_[0]), (control_[1], from_[1]),
color=color, linewidth=.5, linestyle=':')
ax.plot((control_[0], to_[0]), (control_[1], to_[1]),
color=color, linewidth=.5, linestyle=':')
def draw_eg():
debug = False
add_arrow((0.1, 0.1), (1.5, 1.6), rad=0.1, color=colors[1], detail=debug)
add_arrow((0.4, 1.7), (1.4, 0.7), rad=0.8, color=colors[2], detail=debug)
add_arrow((1.9, 1.9), (1.2, 0.1), rad=-0.2, color=colors[3], detail=debug)
add_arrow((1.7, 0.7), (0.3, 1.8), rad=0.5, color=colors[4], detail=debug)
plt.savefig('arrow_example%s.png' % ('_detail' if debug else ''), dpi=600)
fig, ax = plt.subplots(1, 1, figsize=(4, 4))
ax.set_xlim(0, 2)
ax.set_ylim(0, 2)
colors = ['#515151', '#CC9900', '#B177DE', '#37AD6B', '#1A6FDF']
draw_eg()