pythonmatplotlibmplcursors

mplcursors interactivity with endpoints of scatterplots


import pandas as pd
import matplotlib.pyplot as plt
import mplcursors

df = pd.DataFrame(
    {'Universe': ['Darvel', 'MC', 'MC', 'Darvel', 'MC', 'Other', 'Darvel'],
     'Value': [10, 11, 13, 12, 9, 7, 10],
     'Upper': [12.5, 11.3, 15.4, 12.2, 13.1, 8.8, 11.5],
     'Lower': [4.5, 9.6, 11.8, 6, 6.5, 5, 8]})
df['UpperError'] = df['Upper'] - df['Value']
df['LowerError'] = df['Value'] - df['Lower']

colors = ['r', 'g', 'b']

fig, ax = plt.subplots()
for i, universe in enumerate(df['Universe'].unique()):
    to_plot = df[df['Universe'] == universe]
    ax.scatter(to_plot.index, to_plot['Value'], s=16, c=colors[i])
    error = to_plot[['LowerError', 'UpperError']].transpose().to_numpy()
    ax.errorbar(to_plot.index, to_plot['Value'], yerr=error, fmt='o',
                markersize=0, capsize=6, color=colors[i])
    ax.scatter(to_plot.index, to_plot['Upper'], c='w', zorder=-1)
    ax.scatter(to_plot.index, to_plot['Lower'], c='w', zorder=-1)
    
mplcursors.cursor(hover=True)

plt.show()

This does most of what I want, but I want the following changes.

  1. I do not want the mplcursors cursor to interact with the errorbars, but just the scatter plots, including the invisible scatterplots on top and bottom of the errorbars.

  2. I just want the y value to show. For example, the first bar should say "12.5" on the top, "10.0" in the middle, and "4.5" on the bottom.


Solution

  • To have mplcursors only interact with some elements, a list of those elements can be given as the first parameter to mplcursors.cursor(). The list could be built from the return values of the calls to ax.scatter.

    To modify the annotation text shown, a custom function can be connected. In the example below, the label and the y-position are extracted from the selected element and put into the annotation text. Such label can be added via ax.scatter(..., label=...).

    (Choosing 'none' as the color for the "invisible" elements makes them really invisible. To make the code more "Pythonic" explicit indices can be avoided, working with zip instead of with enumerate.)

    import matplotlib.pyplot as plt
    import mplcursors
    import pandas as pd
    
    def show_annotation(sel):
        text = f'{sel.artist.get_label()}\n y={sel.target[1]:.1f}'
        sel.annotation.set_text(text)
    
    df = pd.DataFrame(
        {'Universe': ['Darvel', 'MC', 'MC', 'Darvel', 'MC', 'Other', 'Darvel'],
         'Value': [10, 11, 13, 12, 9, 7, 10],
         'Upper': [12.5, 11.3, 15.4, 12.2, 13.1, 8.8, 11.5],
         'Lower': [4.5, 9.6, 11.8, 6, 6.5, 5, 8]})
    df['UpperError'] = df['Upper'] - df['Value']
    df['LowerError'] = df['Value'] - df['Lower']
    
    colors = ['r', 'g', 'b']
    
    fig, ax = plt.subplots()
    all_scatters = []
    for universe, color in zip(df['Universe'].unique(), colors):
        to_plot = df[df['Universe'] == universe]
        all_scatters.append(ax.scatter(to_plot.index, to_plot['Value'], s=16, c=color, label=universe))
        error = to_plot[['LowerError', 'UpperError']].transpose().to_numpy()
        ax.errorbar(to_plot.index, to_plot['Value'], yerr=error, fmt='o',
                    markersize=0, capsize=6, color=color)
        all_scatters.append(ax.scatter(to_plot.index, to_plot['Upper'], c='none', zorder=-1, label=universe))
        all_scatters.append(ax.scatter(to_plot.index, to_plot['Lower'], c='none', zorder=-1, label=universe))
    
    cursor = mplcursors.cursor(all_scatters, hover=True)
    cursor.connect('add', show_annotation)
    plt.show()
    

    mplcursors with custom text

    PS: You can also show the 'Universe' via the x ticks:

    ax.set_xticks(df.index)
    ax.set_xticklabels(df['Universe'])
    

    If you want to, for short functions you could use the lambda notation instead of writing a separate function:

    cursor.connect('add',
                   lambda sel: sel.annotation.set_text(f'{sel.artist.get_label()}\n y={sel.target[1]:.1f}'))