The following question got some answers showing how to implement arrows on a matplotlib rectilinear axis such that the arrows stick to the end of the spines. I have two problems with these answers. The first is that, on my screen, the implementations using something like ax.plot(1, 0, transform=ax.get_yaxis_transform(), clip_on=False)
all result in an arrow that is not directly centered on top of the spine. Second, I don’t like the appearance of the arrows in those answers. I therefore set out to try to fix it myself, resulting in the code below. Having attempted to place the arrows with a correction equal to half the thickness of the spine, I am now in the following situation: Debugging tells me that the parameters have been set correctly, but when plotting with certain parameters I can see visually that this is not the case.
Question: How can I get the arrows perfectly centered on the end of the spine?
import matplotlib.pyplot as plt
import matplotlib.lines
import matplotlib.axes
import matplotlib.figure
import matplotlib.spines
class AxesArrows:
"""
Place (90 degree) arrows on the tips of the spines in a figure.
The arrow ( '>', say), is given a fixed horizontal width in pixels; and its height
will remain twice this number (in pixels).
"""
def __init__(self, fig, ax: matplotlib.axes.Axes):
self.fig, self.ax = fig, ax
self.arrow_with_pixels = 10
self.linewidth = 1.5
clip_on = False
self.x_symbol = matplotlib.lines.Line2D(xdata=[0.98, 1, 0.98], ydata=[0.02, 0, -0.02], linewidth=self.linewidth,
color='black', transform=self.ax.get_yaxis_transform(),
solid_joinstyle='miter', clip_on=clip_on)
self.y_symbol = matplotlib.lines.Line2D(xdata=[0.02, 0, -0.02], ydata=[0.98, 1, 0.98], linewidth=self.linewidth,
color='black', transform=self.ax.get_xaxis_transform(),
solid_joinstyle='miter', clip_on=clip_on)
# Recalibrate arrows
self.adjust_symbol(event=None)
self.ax.add_artist(self.x_symbol)
self.ax.add_artist(self.y_symbol)
# Connection id(s)
self.cid_xlim_change = None
self.cid_ylim_change = None
self.cid_resize = None
self.connect()
def connect(self):
"""Connect to all the events we need."""
cb_registry = ax.callbacks
self.cid_xlim_change = cb_registry.connect('xlim_changed', self.adjust_symbol)
self.cid_ylim_change = cb_registry.connect('ylim_changed', self.adjust_symbol)
self.cid_resize = self.fig.canvas.mpl_connect('resize_event', self.adjust_symbol)
def adjust_symbol(self, event):
# 1. Get the Axes bounding box in display space. I.e., the pixel location of the axes
# in the figure, where (0,0) is the lower left corner of the figure (window) and (px,py) is the
# upper right corner of the figure; px an py being the number of pixels of the window in x and y directions.
# Looks something like [[93.75 82.5] [675. 660.]]
ax_corners_in_pixels = self.ax.get_window_extent().get_points()
# 2. Get the display (pixel) coordinates of the ax's lower left corner
x0, y0 = ax_corners_in_pixels[0]
# 3. Get the display (pixel) coordinates of the ax's upper right corner
x1, y1 = ax_corners_in_pixels[1]
# 4. Compute the fraction that self.arrow_with_pixels will occupy of the ax's width.
# This is equivalent to the width it will have in the ax's "axes" coordinate system.
arrow_horizontal_fraction = self.arrow_with_pixels / (x1 - x0)
# 5. Compute the fraction that self.arrow_with_pixels would occupy of the ax's height.
arrow_vertical_fraction = self.arrow_with_pixels / (y1 - y0)
# 6. Calculate the number of data points (of the axis, in the vertical direction) that
# The number from 5) corresponds to.
a, b = self.ax.get_ylim()
arrow_vertical_data = arrow_vertical_fraction * (b - a)
# 8. In the (ax's axes coords, ax's data coords) system, the arrow pointing to the right
# and attached to the real axis is now given by the following three points:
# A = (1 - arrow_horizontal_fraction, arrow_vertical_data)
# B = (1, 0)
# C = (1 - arrow_horizontal_fraction, -arrow_vertical_data)
# 8*: An attempt to fix 8.:
bot_spine = self.ax.spines['bottom']
bot_spine_thickness_ax_data_points = bot_spine.get_linewidth() / 72. * self.fig.dpi / (
self.ax.get_window_extent().get_points()[1, 1] - self.ax.get_window_extent().get_points()[0, 1]) * (
self.ax.get_ylim()[1] - self.ax.get_ylim()[0])
A = (1 - arrow_horizontal_fraction, arrow_vertical_data - bot_spine_thickness_ax_data_points / 2)
B = (1, - bot_spine_thickness_ax_data_points / 2)
C = (1 - arrow_horizontal_fraction, -arrow_vertical_data - bot_spine_thickness_ax_data_points / 2)
self.x_symbol.set(xdata=[A[0], B[0], C[0]], ydata=[A[1], B[1], C[1]])
# 9. Does the same as 8), but for the upward arrow. Here, however, the coordinate system is the other way
# around, i.e. (ax's data coords, ax's axes coords)
c, d = self.ax.get_xlim()
arrow_horizontal_data = arrow_horizontal_fraction * (d - c)
D = (-arrow_horizontal_data, 1 - arrow_vertical_fraction)
E = (0, 1)
F = (arrow_horizontal_data, 1 - arrow_vertical_fraction)
self.y_symbol.set(xdata=[D[0], E[0], F[0]], ydata=[D[1], E[1], F[1]])
# Try figsize=(6, 6), dpi=800 (both arrows clearly not centered on spines)
fig = plt.figure(figsize=(6, 6), dpi=200)
ax = fig.add_subplot(projection='rectilinear')
ax.plot([0, 1, 2], [0, 1, 2])
ax.spines[['right', 'top']].set_visible(False)
ax.spines[['left', 'bottom']].set_position(('data', 0))
_ = AxesArrows(fig=fig, ax=ax)
plt.show()
Goal of the code: Place 90 degree arrows centered at the end of each spine. If my implementation is correct, the angle and pixel size of the arrows should remain the same no matter how the axes’ limits are changed and the figure resized.
As far as I understand the spine(‘s lines), the bottom spine’s upper edge lies along axes’ (x-)data=0 line, and that something similar applies to the left spine. Using the code at comment 8), I have the impression that the right-pointing arrow is not correctly centered (perhaps I’m mistaken). Using the code at comment 8*) adjusts the
right-pointing arrow down by half the size of the bottom spine. Still, the plot is not quite correct.