pythonmatplotlib

How to draw scale-independent horizontal bars with tips in matplotlib?


I want to create a plot that shows genomic coding regions as arrows that may contain colorfully highlighted domain regions. In principle it is something like this:

import numpy as np
import matplotlib.patches as patches
import matplotlib.pyplot as plt

def test(bar_height=0.8, figsize=(10, 6), arrow_headlen=0.2, dpi=600):
    X0 = np.arange(0, 10, 1)
    X1 = X0 + 2
    Y = np.arange(0, 10, 1)

    fig = plt.figure(figsize=figsize)
    ax = fig.add_subplot(111)

    data_2_px = ax.transData.transform  # data domain to figure
    px_2_data = ax.transData.inverted().transform  # figure to data domain

    # get arrow head_length as fraction of arrow width
    # so that it doesnt grow longer with longer x-axis
    dy = bar_height * arrow_headlen
    dpx = data_2_px([(0, dy)]) - data_2_px([(0, 0)])
    arrowlen = (px_2_data([(dpx[0, 1], dpx[0, 0])]) - px_2_data([(0, 0)]))[0, 0]

    ax.barh(y=Y, left=X0, width=X1 - X0, height=bar_height, color="0.5")
    for y, x1 in zip(Y, X1):
        yl = y - 0.49 * bar_height  # low arrow corner (avoid being draw 1 px too low)
        yh = y + 0.49 * bar_height  # high arrow corner (avoid being draw 1 px too high)
        arrow = patches.Polygon([(x1, yl), (x1, yh), (x1 + arrowlen, y)], color="0.5")
        ax.add_patch(arrow)

    # highlight parts of arrows
    ax.barh(y=Y, left=X0 + 0.5, width=(X1 - X0) / 2, height=bar_height, color="blue")

    fig.savefig("./test_from_savefig.png", dpi=dpi)
    plt.show()

This draws 10 transcript "arrows" in gray and each of these transcripts contains a region highlighted in blue. When plt.show() opens this in a viewer and I save it from there I get this image (A):

from viewer

The picture that is saved by fig.savefig() with higher DPI however gives this image (B):

from savefig

As you can see the arrow heads are suddenly not flush with the arrow base anymore. It seems that they were scaled differently than the bars. But both of them are defined in the data domain. So shouldn't they still be flush.

Image A is what I want to create:

However, I also want to be able to save this plot as raster graphic in a higher resolution.

Why don't I use FancyArrow?

FancyArrow would be a more straight-forward way of defining arrows. However, they are not drawn in a very reproducible way. Sometimes a FancyArrow is drawn 1 pixel higher or lower. This means If I draw a gray FancyArrow and then a blue rectangle over it, there will sometimes be some visible misalignment (e.g. a 1 pixel gray line visible behind the blue area). I have found that only barh is able to draw a bar of different colors that actually looks like it belongs together.


Solution

  • To have the arrow heads flush with the arrow bodies even when modifying the dpi value, set linewidth=0 in the patches.Polygon initialization.

    Your code then gives this image (saved as PNG with 600 DPI)

    Plot with arrow heads flush with arrow body

    Please note that for some reason, now a thin white line appears at the conjunction of some body-head pairs (see the second one starting from top, for example).

    To go deeper into this latter issue, I have refactored the code using the rectangle corners to define the triangle vertex (which I paste below), but the result looks just the same.

    import numpy as np
    import matplotlib.patches as patches
    import matplotlib.pyplot as plt
    
    def test(arrow_height=0.8, arrow_bodylen=2.0, arrow_headlen=0.2, 
             figsize=(10, 6), dpi=600):
        # Define points corresponding to arrow tails
        X_tail = np.arange(0, 10, 1)
        Y = np.arange(0, 10, 1)
    
        # Create the figure
        fig = plt.figure(figsize=figsize)    
        ax = fig.add_subplot(111)
    
        # Draw arrow bodies (i.e. rectangles)
        h_bars = ax.barh(y=Y,
                         left=X_tail, 
                         width=arrow_bodylen, 
                         height=arrow_height, 
                         color="0.5",
                         linewidth = 0  # though it does not seem to have any effect
                        )
        # Loop through rectangles to add heads (i.e. triangles) to arrow bodies
        for bar in h_bars.patches:
            # `get_corners()` return the rectangle corners, starting from `bar.xy`
            # (here it is lower-left corner) in counter-clockwise order. 
            bar_LL, bar_LR, bar_TR, bar_TL = bar.get_corners()
            assert bar_LR[0] == bar_TR[0] # probably could be removed...
            # Define the point corresponding to the arrow tip
            arrow_tip = ( bar_LR[0] + arrow_height * arrow_headlen,
                         (bar_LR[1] + bar_TR[1]) / 2
                         )
            # Define a triangle between the right end of the bar and the arrow tip
            arrow = patches.Polygon([bar_LR, 
                                     bar_TR, 
                                     arrow_tip
                                     ], 
                                    color="0.5",
                                    linewidth = 0
                                    )
            ax.add_patch(arrow) # draw the triangle
        
        # Highlight parts of the arrow bodies
        ax.barh(y=Y, 
                left=X_tail + 0.25 * arrow_bodylen, 
                width=arrow_bodylen / 2, 
                height=arrow_height, 
                color="blue",
                linewidth = 0
                )
        
        fig.savefig("./test_from_savefig.png", dpi=dpi)
        plt.show()
    
    test()
    

    Hope this helps at least a bit!