bang
bang

Reputation: 17

Annotating a Matplotlib Polycollection bar timeline

My question is: how to annotate a Matplotlib Polycollection bar chart when hovering over the bars using "motion_notify_event"? I have spent the day trying to make this work but I just cant get my head round a polycollection object and how to index it. Hopefully someone can help solve this, I have spent so much time on it I would hate to give up now!

A great solution to scatter, line and bar annotations using "motion_notify_event" is found in the link below: Possible to make labels appear when hovering over a point in matplotlib?

However, the above solutions do not deal with a 'matplotlib.collections.polycollection' object. I am struggling to find ways of accessing the vertices and arrays held in this object using the code in the above solution. Is it possible to index the polycollection; use the index to access a string value in the original data; then annotate the position whilst hovering over it?

I have used a modified example of a Polycollection bar timeline from the below link which I have fused with the hover/annotate examples in the above link. How to individually label bars in Matplotlib plot?

    import datetime as dt
    import matplotlib.pyplot as plt
    import matplotlib.dates as mdates
    from matplotlib.collections import PolyCollection

    data = [(dt.datetime(2018, 7, 17, 0, 15), dt.datetime(2018, 7, 17, 0, 30), 'sleep','nap_1'),
           (dt.datetime(2018, 7, 17, 0, 30), dt.datetime(2018, 7, 17, 0, 45), 'eat', 'snack_1'),
           (dt.datetime(2018, 7, 17, 0, 45), dt.datetime(2018, 7, 17, 1, 0), 'work', 'meeting_1'),
           (dt.datetime(2018, 7, 17, 1, 0), dt.datetime(2018, 7, 17, 1, 30), 'sleep','nap_2'),
           (dt.datetime(2018, 7, 17, 1, 15), dt.datetime(2018, 7, 17, 1, 30), 'eat', 'snack_2'), 
           (dt.datetime(2018, 7, 17, 1, 30), dt.datetime(2018, 7, 17, 1, 45), 'work', 'project_2')]

    cats = {"sleep" : 1, "eat" : 2, "work" : 3}
    colormapping = {"sleep" : "C0", "eat" : "C1", "work" : "C2"}

    verts = []
    colors = []
    for d in data:
        v =  [(mdates.date2num(d[0]), cats[d[2]]-.4),
              (mdates.date2num(d[0]), cats[d[2]]+.4),
              (mdates.date2num(d[1]), cats[d[2]]+.4),
              (mdates.date2num(d[1]), cats[d[2]]-.4),
              (mdates.date2num(d[0]), cats[d[2]]-.4)]
        verts.append(v)
        colors.append(colormapping[d[2]])

    bars = PolyCollection(verts, facecolors=colors)

    fig, ax = plt.subplots()
    ax.add_collection(bars)
    ax.autoscale()
    loc = mdates.MinuteLocator(byminute=[0,15,30,45])
    ax.xaxis.set_major_locator(loc)
    ax.xaxis.set_major_formatter(mdates.AutoDateFormatter(loc))

    ax.set_yticks([1,2,3])
    ax.set_yticklabels(["sleep", "eat", "work"])

    annot = ax.annotate("", xy=(0, 0), xytext=(20, 20), textcoords="offset points",
      bbox=dict(boxstyle="round", fc="w"),
      arrowprops=dict(arrowstyle="->"))
    annot.set_visible(False)


    def update_annot(ind):
        # how to index the polycollection? get_offsets causes an error
        pos = bars.get_offsets()[ind["ind"][0]] 
        # causes 'IndexError: index 5 is out of bounds for axis 0 with size 1'
        annot.xy = pos
        # If we can get an index, can we use it to access the original 'data' to get the 
        # string value at d[3] from data ('nap_1') and use it to annotate?
        text = ""

        annot.set_text(text)
        annot.get_bbox_patch().set_facecolor(cmap(norm(c[ind["ind"][0]])))
        annot.get_bbox_patch().set_alpha(0.4)


    def hover(event):
        vis = annot.get_visible()
        if event.inaxes == ax:
            cont, ind = bars.contains(event)
            if cont:
                update_annot(ind)
                annot.set_visible(True)
                fig.canvas.draw_idle()
            else:
                if vis:
                    annot.set_visible(False)
                    fig.canvas.draw_idle()

    fig.canvas.mpl_connect("motion_notify_event", hover)

    plt.show()

Upvotes: 0

Views: 417

Answers (1)

JohanC
JohanC

Reputation: 80509

The way to get to the desired data is a bit complicated, and can be found out by digging into the debugger:

  • bars.get_paths() gives a list of Path objects (the polygons).
  • this can be indexed to get to an individual bar
  • bars.get_paths()[ind].get_extents() gives the bounding box of the bar
  • bars.get_paths()[ind].get_extents().get_points() gives the lower left and the upper right point of the bounding box.
import datetime as dt
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from matplotlib.collections import PolyCollection

data = [(dt.datetime(2018, 7, 17, 0, 15), dt.datetime(2018, 7, 17, 0, 30), 'sleep', 'nap_1'),
        (dt.datetime(2018, 7, 17, 0, 30), dt.datetime(2018, 7, 17, 0, 45), 'eat', 'snack_1'),
        (dt.datetime(2018, 7, 17, 0, 45), dt.datetime(2018, 7, 17, 1, 0), 'work', 'meeting_1'),
        (dt.datetime(2018, 7, 17, 1, 0), dt.datetime(2018, 7, 17, 1, 30), 'sleep', 'nap_2'),
        (dt.datetime(2018, 7, 17, 1, 15), dt.datetime(2018, 7, 17, 1, 30), 'eat', 'snack_2'),
        (dt.datetime(2018, 7, 17, 1, 30), dt.datetime(2018, 7, 17, 1, 45), 'work', 'project_2')]

cats = {"sleep": 1, "eat": 2, "work": 3}
colormapping = {"sleep": "C0", "eat": "C1", "work": "C2"}

verts = []
colors = []
for d in data:
    v = [(mdates.date2num(d[0]), cats[d[2]] - .4),
         (mdates.date2num(d[0]), cats[d[2]] + .4),
         (mdates.date2num(d[1]), cats[d[2]] + .4),
         (mdates.date2num(d[1]), cats[d[2]] - .4),
         (mdates.date2num(d[0]), cats[d[2]] - .4)]
    verts.append(v)
    colors.append(colormapping[d[2]])

bars = PolyCollection(verts, facecolors=colors)

fig, ax = plt.subplots()
ax.add_collection(bars)
ax.autoscale()
loc = mdates.MinuteLocator(byminute=[0, 15, 30, 45])
ax.xaxis.set_major_locator(loc)
ax.xaxis.set_major_formatter(mdates.AutoDateFormatter(loc))

ax.set_yticks([1, 2, 3])
ax.set_yticklabels(["sleep", "eat", "work"])

annot = ax.annotate("", xy=(0, 0), xytext=(20, 20), textcoords="offset points",
                    bbox=dict(boxstyle="round", fc="w"),
                    arrowprops=dict(arrowstyle="->"))
annot.set_visible(False)

def update_annot(ind):
    pos = bars.get_paths()[ind].get_extents().get_points()[1]
    annot.xy = pos
    text = f"{data[ind][2]}: {data[ind][3]}"
    annot.set_text(text)
    # annot.get_bbox_patch().set_facecolor(colormapping[data[ind][2]])
    annot.get_bbox_patch().set_facecolor(bars.get_facecolors()[ind])
    annot.get_bbox_patch().set_alpha(0.4)

def hover(event):
    vis = annot.get_visible()
    if event.inaxes == ax:
        cont, ind = bars.contains(event)
        if cont:
            update_annot(ind["ind"][0])
            annot.set_visible(True)
            fig.canvas.draw_idle()
        else:
            if vis:
                annot.set_visible(False)
                fig.canvas.draw_idle()

fig.canvas.mpl_connect("motion_notify_event", hover)
plt.show()

example plot

Upvotes: 1

Related Questions