Cobry
Cobry

Reputation: 4548

matplotlib axis arrow tip

I am trying to set an arrow at the end of a an axis in matplotlib. I don't want to remove the spines and replace them with pure arrows because I need their functionalities ...

my implementation is as slight modification of joferkington implementation

import matplotlib.pyplot as plt
import numpy as np

def arrowed_spines(ax=None, arrowLength=30, labels=('X', 'Y'), arrowStyle='<|-'):
    xlabel, ylabel = labels

    for i, spine in enumerate(['left', 'bottom']):
        # Set up the annotation parameters
        t = ax.spines[spine].get_transform()
        xy, xycoords = [1, 0], ('axes fraction', t)
        xytext, textcoords = [arrowLength, 0], ('offset points', t)
        
        # create arrowprops
        arrowprops = dict( arrowstyle=arrowStyle,
                           facecolor=ax.spines[spine].get_facecolor(), 
                           linewidth=ax.spines[spine].get_linewidth(),
                           alpha = ax.spines[spine].get_alpha(),
                           zorder=ax.spines[spine].get_zorder(),
                           linestyle = ax.spines[spine].get_linestyle() )
    
        if spine is 'bottom':
            ha, va = 'left', 'center'
            xarrow = ax.annotate(xlabel, xy, xycoords=xycoords, xytext=xytext, 
                        textcoords=textcoords, ha=ha, va='center',
                        arrowprops=arrowprops)
        else:
            ha, va = 'center', 'bottom'
            yarrow = ax.annotate(ylabel, xy[::-1], xycoords=xycoords[::-1], 
                        xytext=xytext[::-1], textcoords=textcoords[::-1], 
                        ha='center', va=va, arrowprops=arrowprops)
    return xarrow, yarrow

# plot
x = np.arange(-2., 10.0, 0.01)
plt.plot(x, x**2)
plt.gcf().set_facecolor('white')    
ax = plt.gca()
ax.set_xticks([]) 
ax.set_yticks([]) 

ax.spines['left'].set_position('center')
ax.spines['right'].set_color('none')
ax.spines['bottom'].set_position('center')
ax.spines['top'].set_color('none')

arrowed_spines(ax)

plt.show()

The plot result shows a shift in the arrow as the following enter image description here

It seems that a shift of a point or two is consistent in the starting position and the alignment of the arrow with the spine. I don't know how to fix this problem.

Upvotes: 27

Views: 51750

Answers (7)

Klaus Ladner
Klaus Ladner

Reputation: 1

I like Gabriels solution. However the problem remains that the spines do not start at zero. Especially for educational purposes I find it helpful to get the spines to zero. Here ist my slightly improvement for his version incorporating the "zero" and some minor changes.

from matplotlib import pyplot as plt
import numpy as np


def arrowed_spines(
    ax,
    x_width_fraction=0.02,
    x_height_fraction=0.02,
    lw=0,
    ohg=0.3,
    locations=("bottom right", "left up"),
    **arrow_kwargs
):
    """
    Add arrows to the requested spines
    Code originally sourced here: https://3diagramsperpage.wordpress.com/2014/05/25/arrowheads-for-axis-in-matplotlib/
    And interpreted here by @Julien Spronck: https://stackoverflow.com/a/33738359/1474448
    Then corrected and adapted by me for more general applications.
    :param ax: The axis being modified
    :param x_{height,width}_fraction: The fraction of the **x** axis range used for the arrow height and width
    :param lw: Linewidth. If not supplied, default behaviour is to use the value on the current left spine.
    :param ohg: Overhang fraction for the arrow.
    :param locations: Iterable of strings, each of which has the format "<spine> <direction>". These must be orthogonal
    (e.g. "left left" will result in an error). Can specify as many valid strings as required.
    :param arrow_kwargs: Passed to ax.arrow()
    :return: Dictionary of FancyArrow objects, keyed by the location strings.
    """
    # set/override some default plotting parameters if required
    arrow_kwargs.setdefault("overhang", ohg)
    arrow_kwargs.setdefault("clip_on", False)
    arrow_kwargs.update({"length_includes_head": True})
    arrow_kwargs.update({"head_starts_at_zero": True})

    # axis line width
    if lw is None:
        # FIXME: does this still work if the left spine has been deleted?
        lw = ax.spines["left"].get_linewidth()

    annots = {}

    xmin, xmax = ax.get_xlim()
    ymin, ymax = ax.get_ylim()

    # get width and height of axes object to compute
    # matching arrowhead length and width
    fig = ax.get_figure()
    dps = fig.dpi_scale_trans.inverted()
    bbox = ax.get_window_extent().transformed(dps)
    width, height = bbox.width, bbox.height

    # manual arrowhead width and length
    hw = x_width_fraction * (ymax - ymin)
    hl = x_height_fraction * (xmax - xmin)

    # compute matching arrowhead length and width
    yhw = hw / (ymax - ymin) * (xmax - xmin) * height / width
    yhl = hl / (xmax - xmin) * (ymax - ymin) * width / height

    # draw x and y axis
    for loc_str in locations:
        side, direction = loc_str.split(" ")
        assert side in {"bottom", "top", "left", "right", "zero"}, "Unsupported side"
        assert direction in {"up", "down", "left", "right"}, "Unsupported direction"

        if direction in {"left", "right"}:
            if side in {"left", "right"}:
                raise ValueError("Only up/down arrows supported on the left and right")

            dy = 0
            head_width = hw
            head_length = hl

            if side == "zero":
                y = 0
            else:
                y = ymin if side == "bottom" else ymax

            if direction == "right":
                x = xmin
                dx = xmax - xmin
            else:
                x = xmax
                dx = xmin - xmax

        else:
            if side in {"bottom", "up"}:
                raise ValueError(
                    "Only left/right arrows supported on the bottom and up"
                )
            dx = 0
            head_width = yhw
            head_length = yhl

            if side == "zero":
                x = 0
            else:
                x = xmin if side == "left" else xmax

            if direction == "up":
                y = ymin
                dy = ymax - ymin
            else:
                y = ymax
                dy = ymin - ymax

        annots[loc_str] = ax.arrow(
            x,
            y,
            dx,
            dy,
            fc="k",
            ec="k",
            lw=lw,
            head_width=head_width,
            head_length=head_length,
            **arrow_kwargs
        )

    plt.ylim(ymin, ymax)
    plt.xlim(xmin, xmax)

    return annots


fig = plt.figure()
ax = fig.add_subplot(111)
x = np.arange(-2.0, 10.0, 0.01)
ax.plot(x, x**2)
fig.set_facecolor("white")

ax.spines["left"].set_position("zero")
ax.spines["right"].set_color("none")
ax.spines["bottom"].set_position("zero")
ax.spines["top"].set_color("none")

ax.set_xticks([])
ax.set_yticks([])
ax.set_xlabel("X")
ax.xaxis.set_label_coords(1.03, 0.06)
ax.set_ylabel("Y", rotation=0)
ax.yaxis.set_label_coords(0.197, 1.03)

annots = arrowed_spines(ax, locations=("zero right", "zero up"))

plt.show()

[Result][1] [1]: https://i.sstatic.net/f5D1jp6t.png

Hopefully it helps someone.

Upvotes: 0

Laurent C.
Laurent C.

Reputation: 206

There is a solution in the doc here: https://matplotlib.org/3.3.4/gallery/recipes/centered_spines_with_arrows.html, as specified in Sadegh Bakhtiarzadeh's answer.

However, this solution is not satsfying when dispaying the grid, as it moves the edges of the grid accross the axes and the ticks labels.

You can avoid it by replacing:

# Move the left and bottom spines to x = 0 and y = 0, respectively.
ax.spines["left"].set_position(("data", 0))
ax.spines["bottom"].set_position(("data", 0))

By:

# Set the x and y starting tick to 0.
ax.set_ylim(bottom=0)
ax.set_xlim(0)

Full example:

import matplotlib.pyplot as plt
import numpy as np

fig, ax = plt.subplots()
# Set the x and y starting tick to 0.
ax.set_ylim(bottom=0)
ax.set_xlim(0)
# Hide the top and right spines.
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

# Draw arrows (as black triangles: ">k"/"^k") at the end of the axes.  In each
# case, one of the coordinates (0) is a data coordinate (i.e., y = 0 or x = 0,
# respectively) and the other one (1) is an axes coordinate (i.e., at the very
# right/top of the axes).  Also, disable clipping (clip_on=False) as the marker
# actually spills out of the axes.
ax.plot(1, 0, ">k", transform=ax.get_yaxis_transform(), clip_on=False)
ax.plot(0, 1, "^k", transform=ax.get_xaxis_transform(), clip_on=False)

# Some sample data.
x = np.linspace(-0.5, 1., 100)
ax.plot(x, np.sin(x*np.pi))

plt.show()

Upvotes: 0

Sadegh Bakhtiarzadeh
Sadegh Bakhtiarzadeh

Reputation: 121

I found the most straightforward solution in matplotlib documentation. Following is an example:

import matplotlib.pyplot as plt
import numpy as np

fig, ax = plt.subplots()
# Move the left and bottom spines to x = 0 and y = 0, respectively.
ax.spines["left"].set_position(("data", 0))
ax.spines["bottom"].set_position(("data", 0))
# Hide the top and right spines.
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

# Draw arrows (as black triangles: ">k"/"^k") at the end of the axes.  In each
# case, one of the coordinates (0) is a data coordinate (i.e., y = 0 or x = 0,
# respectively) and the other one (1) is an axes coordinate (i.e., at the very
# right/top of the axes).  Also, disable clipping (clip_on=False) as the marker
# actually spills out of the axes.
ax.plot(1, 0, ">k", transform=ax.get_yaxis_transform(), clip_on=False)
ax.plot(0, 1, "^k", transform=ax.get_xaxis_transform(), clip_on=False)

# Some sample data.
x = np.linspace(-0.5, 1., 100)
ax.plot(x, np.sin(x*np.pi))

plt.show()

Upvotes: 12

ImportanceOfBeingErnest
ImportanceOfBeingErnest

Reputation: 339330

There is an example showing how to get arrows as axis decorators in the matplotlib documentation using the mpl_toolkits.axisartist toolkit:

from mpl_toolkits.axisartist.axislines import SubplotZero
import matplotlib.pyplot as plt
import numpy as np


fig = plt.figure()
ax = SubplotZero(fig, 111)
fig.add_subplot(ax)

for direction in ["xzero", "yzero"]:
    # adds arrows at the ends of each axis
    ax.axis[direction].set_axisline_style("-|>")

    # adds X and Y-axis from the origin
    ax.axis[direction].set_visible(True)

for direction in ["left", "right", "bottom", "top"]:
    # hides borders
    ax.axis[direction].set_visible(False)

x = np.linspace(-0.5, 1., 100)
ax.plot(x, np.sin(x*np.pi))

plt.show()

enter image description here


For many cases, the use of the mpl_toolkits.axisartist.axislines module is not desired. In that case one can also easily get arrow heads by using triangles as markers on the top of the spines:

import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(-np.pi, np.pi, 100)
y = 2 * np.sin(x)

rc = {"xtick.direction" : "inout", "ytick.direction" : "inout",
      "xtick.major.size" : 5, "ytick.major.size" : 5,}
with plt.rc_context(rc):
    fig, ax = plt.subplots()
    ax.plot(x, y)

    ax.spines['left'].set_position('zero')
    ax.spines['right'].set_visible(False)
    ax.spines['bottom'].set_position('zero')
    ax.spines['top'].set_visible(False)
    ax.xaxis.set_ticks_position('bottom')
    ax.yaxis.set_ticks_position('left')

    # make arrows
    ax.plot((1), (0), ls="", marker=">", ms=10, color="k",
            transform=ax.get_yaxis_transform(), clip_on=False)
    ax.plot((0), (1), ls="", marker="^", ms=10, color="k",
            transform=ax.get_xaxis_transform(), clip_on=False)

    plt.show()

enter image description here

Upvotes: 17

Gabriel
Gabriel

Reputation: 1910

Here I have combined existing answers from Julien and s3b4s, and made the function more general so that you can specify the axes you wish to modify and the direction of the arrows.

from matplotlib import pyplot as plt
import numpy as np

def arrowed_spines(
        ax,
        x_width_fraction=0.05,
        x_height_fraction=0.05,
        lw=None,
        ohg=0.3,
        locations=('bottom right', 'left up'),
        **arrow_kwargs
):
    """
    Add arrows to the requested spines
    Code originally sourced here: https://3diagramsperpage.wordpress.com/2014/05/25/arrowheads-for-axis-in-matplotlib/
    And interpreted here by @Julien Spronck: https://stackoverflow.com/a/33738359/1474448
    Then corrected and adapted by me for more general applications.
    :param ax: The axis being modified
    :param x_{height,width}_fraction: The fraction of the **x** axis range used for the arrow height and width
    :param lw: Linewidth. If not supplied, default behaviour is to use the value on the current left spine.
    :param ohg: Overhang fraction for the arrow.
    :param locations: Iterable of strings, each of which has the format "<spine> <direction>". These must be orthogonal
    (e.g. "left left" will result in an error). Can specify as many valid strings as required.
    :param arrow_kwargs: Passed to ax.arrow()
    :return: Dictionary of FancyArrow objects, keyed by the location strings.
    """
    # set/override some default plotting parameters if required
    arrow_kwargs.setdefault('overhang', ohg)
    arrow_kwargs.setdefault('clip_on', False)
    arrow_kwargs.update({'length_includes_head': True})

    # axis line width
    if lw is None:
        # FIXME: does this still work if the left spine has been deleted?
        lw = ax.spines['left'].get_linewidth()

    annots = {}

    xmin, xmax = ax.get_xlim()
    ymin, ymax = ax.get_ylim()

    # get width and height of axes object to compute
    # matching arrowhead length and width
    fig = ax.get_figure()
    dps = fig.dpi_scale_trans.inverted()
    bbox = ax.get_window_extent().transformed(dps)
    width, height = bbox.width, bbox.height

    # manual arrowhead width and length
    hw = x_width_fraction * (ymax-ymin)
    hl = x_height_fraction * (xmax-xmin)

    # compute matching arrowhead length and width
    yhw = hw/(ymax-ymin)*(xmax-xmin)* height/width
    yhl = hl/(xmax-xmin)*(ymax-ymin)* width/height

    # draw x and y axis
    for loc_str in locations:
        side, direction = loc_str.split(' ')
        assert side in {'top', 'bottom', 'left', 'right'}, "Unsupported side"
        assert direction in {'up', 'down', 'left', 'right'}, "Unsupported direction"

        if side in {'bottom', 'top'}:
            if direction in {'up', 'down'}:
                raise ValueError("Only left/right arrows supported on the bottom and top")

            dy = 0
            head_width = hw
            head_length = hl

            y = ymin if side == 'bottom' else ymax

            if direction == 'right':
                x = xmin
                dx = xmax - xmin
            else:
                x = xmax
                dx = xmin - xmax

        else:
            if direction in {'left', 'right'}:
                raise ValueError("Only up/downarrows supported on the left and right")
            dx = 0
            head_width = yhw
            head_length = yhl

            x = xmin if side == 'left' else xmax

            if direction == 'up':
                y = ymin
                dy = ymax - ymin
            else:
                y = ymax
                dy = ymin - ymax


        annots[loc_str] = ax.arrow(x, y, dx, dy, fc='k', ec='k', lw = lw,
                 head_width=head_width, head_length=head_length, **arrow_kwargs)

    return annots


fig = plt.figure()
ax = fig.add_subplot(111)
x = np.arange(-2., 10.0, 0.01)
ax.plot(x, x**2)
fig.set_facecolor('white')

annots = arrowed_spines(ax, locations=('bottom right', 'bottom left', 'left up', 'right down'))

plt.show()

Result:

Adding arrows to axes

Outstanding issue: I have attempted to match the linewidth of the existing spines, but for some reason the arrows appear to have a thicker line. Experimenting with this reveals that a spine linewidth of 0.8 matches an arrow linewidth of around 0.3. Not sure why this is - currently you have to set lw=<value> as a manual fix.

Upvotes: 6

s3b4s
s3b4s

Reputation: 93

In order to obtain what you want, Julien's answer is enough after deleting the following section from the arrowed_spines function:

# removing the default axis on all sides:
for side in ['bottom','right','top','left']:
    ax.spines[side].set_visible(False)

# removing the axis ticks
plt.xticks([]) # labels 
plt.yticks([])
ax.xaxis.set_ticks_position('none') # tick markers
ax.yaxis.set_ticks_position('none')

Spines can still be modified after the inclusion of arrows, as you can see here:

enter image description here

Upvotes: 3

Julien Spronck
Julien Spronck

Reputation: 15433

You could remove all spines and expand the arrows to cover the data range (found this code here):

import matplotlib.pyplot as plt
import numpy as np

def arrowed_spines(fig, ax):

    xmin, xmax = ax.get_xlim() 
    ymin, ymax = ax.get_ylim()

    # removing the default axis on all sides:
    for side in ['bottom','right','top','left']:
        ax.spines[side].set_visible(False)

    # removing the axis ticks
    plt.xticks([]) # labels 
    plt.yticks([])
    ax.xaxis.set_ticks_position('none') # tick markers
    ax.yaxis.set_ticks_position('none')

    # get width and height of axes object to compute 
    # matching arrowhead length and width
    dps = fig.dpi_scale_trans.inverted()
    bbox = ax.get_window_extent().transformed(dps)
    width, height = bbox.width, bbox.height

    # manual arrowhead width and length
    hw = 1./20.*(ymax-ymin) 
    hl = 1./20.*(xmax-xmin)
    lw = 1. # axis line width
    ohg = 0.3 # arrow overhang

    # compute matching arrowhead length and width
    yhw = hw/(ymax-ymin)*(xmax-xmin)* height/width 
    yhl = hl/(xmax-xmin)*(ymax-ymin)* width/height

    # draw x and y axis
    ax.arrow(xmin, 0, xmax-xmin, 0., fc='k', ec='k', lw = lw, 
             head_width=hw, head_length=hl, overhang = ohg, 
             length_includes_head= True, clip_on = False) 

    ax.arrow(0, ymin, 0., ymax-ymin, fc='k', ec='k', lw = lw, 
             head_width=yhw, head_length=yhl, overhang = ohg, 
             length_includes_head= True, clip_on = False)


# plot
x = np.arange(-2., 10.0, 0.01)
plt.plot(x, x**2)
fig = plt.gcf()
fig.set_facecolor('white') 
ax = plt.gca()

arrowed_spines(fig, ax)

plt.show()

And this code gives ...

Upvotes: 9

Related Questions