Interactive annotations only show up for one data set in matplotlib

Viewed 236

I wrote a script with annotations that get displayed upon hovering over data points based on some of the answers to similar questions by the user ImportanceOfBeingErnest. One of the changes I've made is that I only change the text and position of a single annotation and use it for more than one data set. This seems to cause the problem that the annotation only gets displayed for the last data set (or plotter, as I called them in my script) in the list of all data sets/ plotters.

How can I get the annotation to display for all data points of both scatter plots in my script? Do I have to make a new annotation for each data set and update them separately?

import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.ticker import AutoMinorLocator, MultipleLocator
from scipy.stats import linregress


# All data in pA*s
gc_data = {
'KAL1':{'Toluol':400754.594,'1-Octen':53695.014,'Decan':6443.483,'1-Nonannitril':48984.504},
'KAL2':{'Toluol':417583.343,'1-Octen':29755.3,'Decan':16264.896,'1-Nonannitril':16264.896},
'KAL3':{'Toluol':442378.88,'1-Octen':18501.12,'Decan':19226.245,'1-Nonannitril':16200.611},
'KAL4':{'Toluol':389679.589,'1-Octen':13381.415,'Decan':68549.002,'1-Nonannitril':11642.123},
'KAL5':{'Toluol':423982.487,'1-Octen':6263.286,'Decan':53580.809,'1-Nonannitril':4946.271},
'KAL6':{'Toluol':351754.329,'1-Octen':8153.602,'Decan':105408.823,'1-Nonannitril':7066.718}
}

# All data in mg
mass_data = {
'KAL1':{'1-Octen':149.3,'Decan':17.8,'1-Nonannitril':154.7},
'KAL2':{'1-Octen':80.6,'Decan':43.7,'1-Nonannitril':82.8},
'KAL3':{'1-Octen':50.4,'Decan':51.8,'1-Nonannitril':51.5},
'KAL4':{'1-Octen':40.9,'Decan':206.9,'1-Nonannitril':40.8},
'KAL5':{'1-Octen':18.0,'Decan':155.2,'1-Nonannitril':16.4},
'KAL6':{'1-Octen':23.4,'Decan':301.4,'1-Nonannitril':23.6},
}





def update_annot(line, annot, ind):

    if isinstance(line, matplotlib.collections.PathCollection):
        x,y = line.get_offsets().transpose()
    elif isinstance(line, matplotlib.lines.Line2D):
        x,y = line.get_data()
    else:
        quit('No getter of x,y Data for this type of plotter.')

    annot.xy = (x[ind["ind"][0]], y[ind["ind"][0]])
    text = "x = {}\ny= {}".format(x[ind["ind"][0]], y[ind["ind"][0]])
    annot.set_text(text)


def hover(event,fig,annot):

    if event.inaxes in fig.axes:
        plotters = fig.axes[0].collections
        for plotter in plotters:
            cont, ind = plotter.contains(event)
            if cont:
                update_annot(plotter, annot, ind)
                annot.set_visible(True)
                fig.canvas.draw_idle()
            else:
                if annot.get_visible():
                    annot.set_visible(False)
                    fig.canvas.draw_idle()




def get_data(substance,standard):

    m = [mass_data[i][substance]/mass_data[i][standard] for i in mass_data] 
    A = [gc_data[i][substance]/gc_data[i][standard] for i in mass_data]

    return A,m



def plot(substance,standard,save=None):

    A,m = get_data(substance,standard)

    A_baddata = A.pop(1)
    m_baddata = m.pop(1)

    # Linear regression 
    a,b,rval,pval,stdev = linregress(A,m)

    # Plotting
    fig, ax = plt.subplots(figsize=(6,6))

    # Data inputs
    ax.scatter(A,m,marker='o') # Measured data
    ax.scatter(A_baddata,m_baddata,marker='o',c='r')

    xmin,xmax = ax.get_xlim()
    ymin,ymax = ax.get_ylim()
    ax.plot(np.array([-2*max(A),2*max(A)]),np.array([-2*max(A),2*max(A)])*a + b) # graph from regression parameters
    ax.set_ylim(ymin,ymax)
    ax.set_xlim(xmin,xmax)

    # General formatting
    ax.tick_params(axis='both',which='both',labelsize=12,direction='in')

    ax.xaxis.set_major_locator(MultipleLocator(1))
    ax.yaxis.set_major_locator(MultipleLocator(1))
    ax.xaxis.set_minor_locator(AutoMinorLocator())
    ax.yaxis.set_minor_locator(AutoMinorLocator())

    ax.set_ylabel(r'$m_{\mathrm{Substanz}}\quad/\quadm_{\mathrm{Standard}}$')
    ax.set_xlabel(r'$A_{\mathrm{Substanz}}\quad/\quadA_{\mathrm{Standard}}$')

    # Description Box
    textstr='{}{}\n'.format('Substanz: ',substance)
    textstr+='{}{}\n'.format('Standard: ',standard)
    textstr+='{}{:.5f}\n'.format('a = ',a)
    textstr+='{}{:.5f}\n'.format('b = ',b)
    textstr+='{}{:.5f}\n'.format(r'$R^{2}$ = ',rval)
    textstr+='{}{:.5f}\n'.format(r'$p$ = ',pval)
    textstr+='{}{:.5f}'.format(r'$\bar X = $',stdev)

    props = dict(boxstyle='round', fc='#96FBFF', ec='#3CF8FF', alpha=0.5)
    ax.text(0.05, 0.95, textstr, transform=ax.transAxes, fontsize=10,
            verticalalignment='top', bbox=props)


    if save:
        plt.savefig(substance+'.svg' ,bbox_inches='tight', transparent=True)
    else:
        # Hovering annotation
        ################################################################################################

        # for i in range(len())
        annot = ax.annotate("", xy=(0,0), xytext=(1,1),textcoords="offset points",
                            bbox=dict(boxstyle="round", fc="w", alpha=0.4),
                            arrowprops=dict(arrowstyle="->"))
        annot.set_visible(False)
        ################################################################################################

        fig.canvas.mpl_connect("motion_notify_event", lambda event: hover(event, fig, annot))
        plt.show()

plot('1-Nonannitril','Decan',0)
1 Answers

The main problem is that the hover event gets triggered by the line instead of by the nearby scatter dots. So, this line should be excluded when connecting the motion_notify_event.

Since ImportanceOfBeingErnest's and others posts about how to create annotations, they developed the mplcursors library to strongly simplify the creation of this kind of annotations.

With mplcursors you can simply call mplcursors.cursor(ax.collections, hover=True) and automatically an annotation with x and y positions would be created. But easily can go much further. The example below also shows how to display the artist's label (here the 'artist' is one collection of scatter dots). Also, how to use the artist's color for the background of the annotation. Further, an extra attribute is added to the artist with a list of names. These names are then added to the annotation.

The code leaves out some of the elements that aren't relevant for the annotations, such as the large text.

import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.ticker import AutoMinorLocator, MultipleLocator
from scipy.stats import linregress
import mplcursors
from matplotlib.colors import to_rgb

# All data in pA*s
gc_data = {
    'KAL1': {'Toluol': 400754.594, '1-Octen': 53695.014, 'Decan': 6443.483, '1-Nonannitril': 48984.504},
    'KAL2': {'Toluol': 417583.343, '1-Octen': 29755.3, 'Decan': 16264.896, '1-Nonannitril': 16264.896},
    'KAL3': {'Toluol': 442378.88, '1-Octen': 18501.12, 'Decan': 19226.245, '1-Nonannitril': 16200.611},
    'KAL4': {'Toluol': 389679.589, '1-Octen': 13381.415, 'Decan': 68549.002, '1-Nonannitril': 11642.123},
    'KAL5': {'Toluol': 423982.487, '1-Octen': 6263.286, 'Decan': 53580.809, '1-Nonannitril': 4946.271},
    'KAL6': {'Toluol': 351754.329, '1-Octen': 8153.602, 'Decan': 105408.823, '1-Nonannitril': 7066.718}
}

# All data in mg
mass_data = {
    'KAL1': {'1-Octen': 149.3, 'Decan': 17.8, '1-Nonannitril': 154.7},
    'KAL2': {'1-Octen': 80.6, 'Decan': 43.7, '1-Nonannitril': 82.8},
    'KAL3': {'1-Octen': 50.4, 'Decan': 51.8, '1-Nonannitril': 51.5},
    'KAL4': {'1-Octen': 40.9, 'Decan': 206.9, '1-Nonannitril': 40.8},
    'KAL5': {'1-Octen': 18.0, 'Decan': 155.2, '1-Nonannitril': 16.4},
    'KAL6': {'1-Octen': 23.4, 'Decan': 301.4, '1-Nonannitril': 23.6},
}

def update_annot(sel):
    x, y = sel.target
    label = sel.artist.get_label()
    new_text = f'{label}\nx: {x:.2f}\ny: {y:.2f}'
    # append the name
    new_text += '\n' + sel.artist.data_names[sel.target.index]
    sel.annotation.set_text(new_text)
    # get the color of the scatter dots, make them whiter and use that as background color for the annotation
    r, g, b = to_rgb(sel.artist.get_facecolor())
    sel.annotation.get_bbox_patch().set(fc=((r + 2) / 3, (g + 2) / 3, (b + 2) / 3), alpha=0.7)

def get_data(substance, standard):
    m = [mass_data[i][substance] / mass_data[i][standard] for i in mass_data]
    A = [gc_data[i][substance] / gc_data[i][standard] for i in mass_data]
    return A, m

def plot(substance, standard, save=None):
    global measured_names, baddata_names
    A, m = get_data(substance, standard)
    measured_names = list(mass_data.keys())

    A_baddata = A.pop(1)
    m_baddata = m.pop(1)
    baddata_names = [measured_names.pop(1)]

    # Linear regression
    a, b, rval, pval, stdev = linregress(A, m)

    # Plotting
    fig, ax = plt.subplots(figsize=(6, 6))

    # Data inputs
    scat1 = ax.scatter(A, m, marker='o', label='Measured data')  # Measured data
    scat1.data_names = measured_names
    scat2 = ax.scatter(A_baddata, m_baddata, marker='o', c='r', label='Bad data')
    scat2.data_names = baddata_names

    xmin, xmax = ax.get_xlim()
    ymin, ymax = ax.get_ylim()
    ax.plot(np.array([-2 * max(A), 2 * max(A)]),
            np.array([-2 * max(A), 2 * max(A)]) * a + b)  # graph from regression parameters
    ax.set_ylim(ymin, ymax)
    ax.set_xlim(xmin, xmax)

    ax.set_ylabel(r'$m_{\mathrm{Substanz}}\quad/\quadm_{\mathrm{Standard}}$')
    ax.set_xlabel(r'$A_{\mathrm{Substanz}}\quad/\quadA_{\mathrm{Standard}}$')

    # Hovering annotation
    # cursor = mplcursors.cursor(ax.collections, hover=True)
    cursor = mplcursors.cursor([scat1, scat2], hover=True)
    cursor.connect("add", update_annot)
    plt.show()

plot('1-Nonannitril', 'Decan', 0)

dynamic annotations with mplcursors

Related