Introduction

One thing has been bothering me for a while now. On the charts it is very difficult to ascertain the exact value (i.e. height) of a column. I don’t want to start adding more lines or detail on the y-axis. So, what I’d like to do is have a bar’s value/height be displayed when I hover over it with the mouse pointer. Perhaps even more information (i.e. country, year, age-group) in the same annotation/pop-up.

I had thought about simply labelling each column, but given the possible number of columns and their width I didn’t think that would look particularly good or really solve the problem.

So, now the reserach begins. I have determined that Matplotlib does provide an event model, and that there are at least a couple ways of adding text to a chart. In this case I am thinking the annotate() function will be a better choice than the text() function. So for now I will focus my research and testing on annotate().

An Annotation is a Text that can refer to a specific position xy. Optionally an arrow pointing from the text to xy can be drawn

matplotlib.text

Don’t know how the research and coding will flow. Nor how much of my testing I will include in the post. Be warned!

Capturing the Mouse Hovering Over a Bar

I am going to start by writing a function to be passed to the Matplotlib event handler, mpl_connect(), as a “callback”.

In computer programming, a callback, also known as a “call-after” function, is any executable code that is passed as an argument to other code; that other code is expected to call back (execute) the argument at a given time. This execution may be immediate as in a synchronous callback, or it might happen at a later time as in an asynchronous callback. Programming languages support callbacks in different ways, often implementing them with subroutines, lambda expressions, blocks, or function pointers.

Callback

This function will process the information returned by mpl_connect() to determine which bar is being hovered and display the appropriate info on the chart.

I am going to start with very simple test code and work my way up. Based on the documentation the event we will be tracking is ‘motion_notify_event’. But as I don’t want a lot responses at the moment I am going to use ‘button_press_event’ and print, to the terminal, the ‘event’ object returned to the callback.

The events that are triggered are also a bit richer vis-a-vis matplotlib than standard GUI events, including information like which matplotlib.axes.Axes the event occurred in. The events also understand the matplotlib coordinate system, and report event locations in both pixel and data coordinates.

Event handling and picking

For testing, added the call to mpl_connect() in plot_1ma().

PS R:\learn\py_play> git diff HEAD
diff --git a/population/chart/chart.py b/population/chart/chart.py
index 319476c..e86fc21 100644
--- a/population/chart/chart.py
+++ b/population/chart/chart.py
@@ -65,6 +65,11 @@ def get_xticks(nbr_lbls=21, nbr_bars=1):
   return b_width, lbl_x, x_ticks


+def onclick(event):
+  print(event)
+
+
 def get_agrp_lbls():
   ag_lbls = []
   for i in range(0, 100, 5):
@@ -87,6 +92,8 @@ def plot_1ma(plot_dtls):
   for pyr in p_years[0].keys():
     c_rects.append(ax.bar(x_bars[ibar], p_years[0][pyr], bar_wd, label=pyr))
     ibar += 1
+  # event handling
+  cid = fig.canvas.mpl_connect('button_press_event', onclick)
   # Add some text for labels, title and custom x-axis tick labels, etc.
   if do_percent:
     ax.set_ylabel('Population (% Annual Total)')
PS R:\learn\py_play>

Then, in a Anaconda terminal window I ran a test #1 of chart.py.

(base-3.8) PS R:\learn\py_play> python.exe r:/learn/py_play/population/chart/chart.py -t 1
args.nbr_yrs 2

chart data: [['Zimbabwe'], ['2005', 2], ['all']]

button_press_event: xy=(520, 403) xydata=(5.302222222222221, 1055.3607720466698) button=1
  dblclick=False inaxes=AxesSubplot(0.125,0.11;0.775x0.77)

Turns out we can also access the x, y, xdata and ydata elements directly.

def onclick(event):
  print(event)
  print(f"{'double' if event.dblclick else 'single'} click: button={event.button}, x={event.x}, y={event.y}, xdata={event.xdata}, ydata={event.ydata}")
(base-3.8) PS R:\learn\py_play> python.exe r:/learn/py_play/population/chart/chart.py -t 1
args.nbr_yrs 2

chart data: [['Zimbabwe'], ['2005', 2], ['all']]

button_press_event: xy=(620, 215) xydata=(7.257777777777776, 436.7282416626154) button=1 dblclick=False inaxes=AxesSubplot(0.125,0.11;0.775x0.77)
single click: button=1, x=620, y=215, xdata=7.257777777777776, ydata=436.7282416626154

Of course those two lines would print every time I clicked winthin the chart. And whether or not I clicked on a bar. Just anywhere in the chart window, even outstide the chart proper. In the following the first click was on a bar, the second within the chart border in an empty space, and the last, a double click, on the chart title.

(base-3.8) PS R:\learn\py_play> python.exe r:/learn/py_play/population/chart/chart.py -t 1
args.nbr_yrs 2


chart data: [['Zimbabwe'], ['2005', 2], ['all']]

button_press_event: xy=(521, 401) xydata=(5.321777777777776, 1048.7795749149245) button=1 dblclick=False inaxes=AxesSubplot(0.125,0.11;0.775x0.77)
single click: button=1, x=521, y=401, xdata=5.321777777777776, ydata=1048.7795749149245
button_press_event: xy=(989, 404) xydata=(14.473777777777773, 1058.6513706125425) button=1 dblclick=False inaxes=AxesSubplot(0.125,0.11;0.775x0.77)
single click: button=1, x=989, y=404, xdata=14.473777777777773, ydata=1058.6513706125425
button_press_event: xy=(804, 676) xydata=(None, None) button=1 dblclick=True inaxes=None
double click: button=1, x=804, y=676, xdata=None, ydata=None

So what do we do with this not so helpful information?

Determine if Mouse is in a Bar

For now I will continue to use the click event. I don’t want the terminal window swamped with output. Which would currently happen if we switched to the motion_notify_event — which we eventually will.

I am using ax.bar() to add bars to the chart. Passing in the information needed to do so properly. We call this function once for each set of bars. I.E. for plot_1ma(), once for each year in the plot. And, though I had no idea why — I followed some sample code — I saved what the function calls returned to a list, c_rects. Turns out the returned value is a BarContainer (matplotlib.container). And, a matplotlib.pyplot.bar has a callable property ‘contains’. Which determines if an event is within the bar.

Well we have, effectively, a list of lists of bars. So we need to traverse the inner lists and check if our click event is within a bar. If so do something appropriate. Let’s give that a try.

def onclick(event):
  #print(event)
  #print(f"{'double' if event.dblclick else 'single'} click: button={event.button}, x={event.x}, y={event.y}, xdata={event.xdata}, ydata={event.ydata}")
  for p_yr in c_rects:
    for p_bar in c_rects[p_yr]:
      cont, ind = p_bar.contains(event)
      if cont:
        print(f"{ind}\n{event}")

Do you see our problem? Well, have a look.

(base-3.8) PS R:\learn\py_play> python.exe r:/learn/py_play/population/chart/chart.py -t 1
args.nbr_yrs 2


chart data: [['Zimbabwe'], ['2005', 2], ['all']]

button_press_event: xy=(523, 349) xydata=(5.360888888888888, 877.6684494895478) button=1 dblclick=False inaxes=AxesSubplot(0.125,0.11;0.775x0.77)
single click: button=1, x=523, y=349, xdata=5.360888888888888, ydata=877.6684494895478
Traceback (most recent call last):
  File "E:\appDev\Miniconda3\envs\base-3.8\lib\site-packages\matplotlib\cbook\__init__.py", line 216, in process
    func(*args, **kwargs)
  File "r:/learn/py_play/population/chart/chart.py", line 71, in onclick
    for p_yr in c_rects:
NameError: name 'c_rects' is not defined

Using a Closure

c_rects’ is created in plot_1ma(). There is no way for onclick(), being defined outside the scope of plot_1ma(), can access that variable. And, adding a ‘global c_rects’ to onclick() won’t work, as ‘c_rects’ is not in the modules global scope. I did think about adding a global ‘c_rects’ and let the plotting functions use it. But, that didn’t seem to me to be the best of coding practices. I could also define onclick() inside plot_1ma() where it would have access to ‘c_rects’. But, then I’d have to do so in each of the current plotting functions and any new ones I might create over time. Again, not good programming practice. So, I am going to try using, what I believe is, the concept of a closure.

A Closure is a function object that remembers values in enclosing scopes even if they are not present in memory.

Closures

Essentially, I am going to write a function, that defines an inner function which it then returns. That inner function will have access to data passed in the creator function’s parameters. Let’s give it a shot.

def cb_click(bar_cont, plot_for, lgnds, x_lbls):
  def callback(event):

    i = 0
    for grp_cont in bar_cont:
      j = 0
      for p_bar in grp_cont:
        if event.inaxes == ax:
          cont, _ = p_bar.contains(event)
          if cont:
            print(f"{p_bar}\n{event}")
            return
        j += 1
      i += 1
  return callback

# and in plot_1ma(), have added
  onclick = cb_click(c_rects)
  c_id = fig.canvas.mpl_connect('button_press_event', onclick)
(base-3.8) PS R:\learn\py_play> python.exe r:/learn/py_play/population/chart/chart.py -t 1
args.nbr_yrs 2

chart data: [['Zimbabwe'], ['2005', 2], ['all']]

Rectangle(xy=(-0.166667, 0), width=0.333333, height=1771.44, angle=0)
button_press_event: xy=(250, 506) xydata=(0.022222222222221255, 1394.2924243315506) button=1 dblclick=False inaxes=AxesSubplot(0.125,0.11;0.775x0.77)

Well, still not horribly helpful. But we do have the height (value) of the bar. And, I can’t actually show you, but if I click outside a bar, nothing gets printed to the terminal window. So, progress. Let’s trying displaying the height on the chart. We are again going to run into the variable scope issue. To write on the chart we need access to it. So, let’s make sure our closure get’s that as well.

def cb_create(ax, fig, bar_cont):
  def callback(event):
    #print(event)
    #print(f"{'double' if event.dblclick else 'single'} click: button={event.button}, x={event.x}, y={event.y}, xdata={event.xdata}, ydata={event.ydata}")
    for grp_cont in bar_cont:
      for p_bar in grp_cont:
        cont, _ = p_bar.contains(event)
        if cont:
          #print(f"{p_bar}\n{event}")
          b_ht = p_bar.get_height()
          x = p_bar.get_x()+p_bar.get_width()/2.
          y = p_bar.get_y()+b_ht
          annot = ax.annotate(f"{b_ht}", xy=(x,y), xytext=(-20,20),textcoords="offset points",
                    bbox=dict(boxstyle="round", fc="gray", ec="b", lw=2, alpha=0.4),
                    arrowprops=dict(arrowstyle="->"))
          annot.set_visible(True)
          fig.canvas.draw_idle()
          return

  return callback 

Tidying Things Up

Unfortunately our annotations remain on the chart cluttering things up. That is because we create a new annotation each time the callback is executed. And, we want the annotation to disappear when the mouse is not over a bar on the chart. So, let’s deal with both of those issues. The first problem can be dealt with by moving the annotations creation outside of callback()’s definition. Becuase we are creating a closure it should remain available when the callback is executed. For the second we need to check if the annotation is visible and turn it off if the mouse pointer is not currently over a bar on the chart. Once we have it working with the button_press_event event, let’s change our call to mpl_connect() to use the motion_notify_event. I end up with the following.

def cb_create(ax, fig, bar_cont):
  annot = ax.annotate(f"", xy=(0,0), xytext=(-5,20), textcoords="offset points",
                    bbox=dict(boxstyle="round", fc="gray", ec="b", lw=2, alpha=0.4),
                    arrowprops=dict(arrowstyle="->"))
  annot.set_visible(False)

  def callback(event):
    #print(event)
    #print(f"{'double' if event.dblclick else 'single'} click: button={event.button}, x={event.x}, y={event.y}, xdata={event.xdata}, ydata={event.ydata}")
    for grp_cont in bar_cont:
      for p_bar in grp_cont:
        cont, _ = p_bar.contains(event)
        if cont:
          #print(f"{p_bar}\n{event}")
          b_ht = p_bar.get_height()
          x = p_bar.get_x()+p_bar.get_width()/2.
          y = p_bar.get_y()+b_ht
          annot.xy = (x,y)
          text = f"{b_ht}"
          annot.set_text(text)
          annot.set_visible(True)
          fig.canvas.draw_idle()
          return
        else:
          annot.set_visible(False)
          fig.canvas.draw_idle()

  return callback 

## and in plot_1ma() I now have
  # event handling
  onmover = cb_create(ax, fig, c_rects)
  cid = fig.canvas.mpl_connect('motion_notify_event', onmover)

Done?

And there you go. Job done. Well more or less. Don’t know about you, but I can’t get the values to show for the two oldest age groups. The bars are just too short/small for the mouse resolution to resolve. It’s plenty tough enough to get the annotation to show for the 90-94 age group. And, I am thinking it would be nice to have more information in the pop-up box. For example something like:

chart displaying an annotation with more information

But the mouseover works, so, for today, that’s it. See you next time (semi-weekly schedule) when we will tackle dealing with the above potential improvements.

Oops! Almost forgot. We need to add the mouseover code to our two other plot functions, plot_m1a() and plot_mm1(), before calling it a day. A little testing of those additions likely wouldn’t hurt either.

Resources

Matplotlib Annotation and Events

Python