Pure matplotlib implementation of crosshair with data labels on axes

Hello, this might be useful for someone.

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import BoxStyle
from matplotlib.path import Path

class MyStyle3:
    """A RIGHT pointy box adopted from custom_boxstyle01"""

    def __init__(self, pad=0.3):

        self.pad = pad
        super().__init__()

    def __call__(self, x0, y0, width, height, mutation_size):
        # padding
        pad = mutation_size * self.pad
        # width and height with padding added
        width = width
        height = height + 2.*pad
        # boundary of the padded box
        x0, y0 = x0 - pad, y0 - pad
        x1, y1 = x0 + width, y0 + height

        # return the new path
        # (x0, y0) : lower left corner of rectangle
        # (x1, y1) : upper right corner of rectangle
        return Path([(x0, y0),
                     (x1, y0), (x1,(y0+y1)*.25), (x1+pad, (y0+y1)/2.),
                     (x1,(y0+y1)*.75),(x1, y1), 
                      (x0, y1), (x0, y0)], closed=True)


class MyStyle2:
    """A LEFT pointy box adopted from custom_boxstyle01"""

    def __init__(self, pad=0.3):

        self.pad = pad
        super().__init__()

    def __call__(self, x0, y0, width, height, mutation_size):

        # padding
        pad = mutation_size * self.pad
        # width and height with padding added
        width = width
        height = height + 2.*pad
        # boundary of the padded box
        x0, y0 = x0 + pad, y0 - pad
        x1, y1 = x0 + width, y0 + height
        # print(f"{mutation_size} {pad} tip point: {x0-pad}, {(y0+y1)/2.}")
        # return the new path
        # (x0, y0) : lower left corner of rectangle
        # (x1, y1) : upper right corner of rectangle
        return Path([(x0, y0),
                     (x1, y0), (x1, y1), (x0, y1),(x0,(y0+y1)*.75),
                     (x0-pad, (y0+y1)/2.), (x0,(y0+y1)*.25), (x0, y0)], closed=True)


class SnaptoCursorEx:
    """
    Like Cursor but the crosshair snaps to the nearest x point
    """

    def __init__(self, ax, x, y):
    
        self.ax = ax
        # self.fp = fp
        self.lx = ax.axhline(color='k', lw=0.8, ls="--")  # the horiz line
        self.ly = ax.axvline(color='k', lw=0.8, ls="--")  # the vert line
        self.x = x
        self.y = y

        self.xm = len(x)

        BoxStyle._style_list["lpointy"] = MyStyle2  # Register the custom style.
        BoxStyle._style_list["rpointy"] = MyStyle3  # Register the custom style.

        self.txt = ax.text( 0.5, 0.5, '',size=12, transform=ax.transAxes)

        self.crossMarker, = ax.plot(0, 0, linewidth=0, marker="o", color="red", markersize=np.sqrt(300), markerfacecolor='none', markeredgewidth=3)

        # along y-axis
        self.tvL = ax.text(0.5, 0.8, "", size=15, va="center", ha="left", rotation=0, bbox=dict(boxstyle="rpointy, pad=0.3", fc="yellow", alpha=0.7))
        self.tvR = ax.text(0.5, 0.8, "", size=15, va="center", ha="left", rotation=0, bbox=dict(boxstyle="lpointy, pad=0.3", fc="green", alpha=0.7))
        self.tvCx = ax.text(0.5, 0.8, "", size=12, va="center", ha="left", color='w', rotation=0, bbox=dict(boxstyle="lpointy, pad=0.3", fc="m", alpha=0.7))
        

        # along x-axis
        # text location in axes coords
        self.th = ax.text(
            0, 0, '', ha="center", va="top", rotation=0, size=15, color='w',
            bbox=dict(boxstyle="rarrow,pad=0.0", fc="none", ec="b", lw=2))
        bb = self.th.get_bbox_patch()
        bb.set_boxstyle("square", pad=0.0)
        self.th.set_bbox(dict(alpha=0.5, fc="red", ec="none", lw=2))
        
        # top marker only
        self.tm, = ax.plot(0., 1.0, 'gv', linestyle='None', markersize=10, clip_on=False, visible=False)


    def on_mouse_move(self, event):

        if not event.inaxes: return

        x, y = event.xdata, event.ydata

        indx = np.searchsorted(self.x, [x])[0]
        if indx < self.xm:
            x = self.x[indx]
            y = self.y[indx]
        elif indx == self.xm:
            x = self.x[indx-1]
            y = self.y[indx-1]        
        
        # update the line positions
        self.lx.set_ydata(y)
        self.ly.set_xdata(x)

        # print(x, y)
        self.crossMarker.set_data(x, y)

        fig.canvas.draw()
        bottom, top = ax.get_ylim()
        dy = (top-bottom)*0.01
        self.th.set_text("{0:.3f}".format(x))
        self.th.set_position((x, bottom-dy))

        left, right = ax.get_xlim()
        dx = (right-left)*0.01
        
        self.tvL.set_text("{0:.3f} ".format(y))    # right-pointy
        self.tvL.set_position((left, y))           # left of y-axis
        self.tvL.set_ha('right')                   # right text alignment

        self.tvR.set_text(" {0:.3f}".format(y))    # left-pointy
        self.tvR.set_position((right, y))          # Rightside of y-axis
        self.tvR.set_ha('left')                    # left text alignment
        
        self.tvCx.set_text(" x={0:.3f}\n y={1:.3f}\n cross-demo".format(x, y))
        self.tvCx.set_position((x+3*dx, y))         # cross point x-offset +3*dx (marker size)
        self.tvCx.set_ha('left')                    # left text alignment
        
        # self.txt.set_text('x={0:,.3f}, y={1:,.3f}'.format(x, y))
        # self.txt.set_position((0.5, top-dy))
        
        self.tm.set_data(x, top+dy)
        self.tm.set_visible(True)

        self.ax.figure.canvas.draw_idle()

if __name__ == "__main__": 

    t = np.arange(0.0, 1.0, 0.01)
    s = np.sin(2*2*np.pi*t)
    fig, ax = plt.subplots()

    cursor = SnaptoCursorEx(ax, t, s)
    fig.canvas.mpl_connect('motion_notify_event', cursor.on_mouse_move)

    ax.plot(t, s, 'o')

    plt.show()
2 Likes