Enhancing the Annotation and the Mouseover Functionality

Last time we managed a basic chart annotation, of bar height, on a mouseover of any bar on the chart. We currently only provide the bars height/value. It would be nice to add some informtation to the annotation pop-up. Specifically the country, year and age group to which the bar belongs. Would save the user having to sort that out from the axis labels and legend. And, we should probably indicate whether the bar value is a count or a percentage. Though the latter might be unnecessary.

And, the mouseover doesn’t work with the very smallest of bars. There probably just isn’t enough resolution in the chart or the mouse’s sensitivity. Would be nice to come up with a work around for that issue. I am thinking that if the x-axis value of the mouse pointer is in the rnage of the x-axis boundaries and very near 0 on the y-axis we should select that bar and display the annotation with that bar’s information.

I think I will start with the small bar issue. Though I don’t think either one should be particularly difficult. But, the first will certainly require some meaningful changes in our code.

Small Bars

Note, for testing while I develop this enhancement, I will once again use the click event rather than the mouse movement event.

Okay, we will if the first test fails need to add a 2nd test that checks for the possibility of a small bar below or above the mouse. In our development of this feature we saw that the bar and event data contained the following information. For the values below I clicked the smallest bar that I could on each of the two charts.

# country = Zimbabwe
Rectangle(xy=(16.8333, 0), width=0.333333, height=13.528, angle=0)
button_press_event: xy=(1116, 86) xydata=(16.957333333333327, 12.241026665046206) button=1 dblclick=False inaxes=AxesSubplot(0.125,0.11;0.775x0.77)
# country = China
Rectangle(xy=(17.8333, 0), width=0.333333, height=1038.16, angle=0)
button_press_event: xy=(1168, 86) xydata=(17.974222222222217, 887.8596383568329) button=1 dblclick=False inaxes=AxesSubplot(0.125,0.11;0.775x0.77)

In order to determine whether or not we are over a “small bar” we can’t really test the bar height. That will vary considerably depending on the country for which it is plotted. But you will note that in the button press events shown above, the y value was the same for both charts. So let’s try checking for a y value of less than 100 and a xdata value that is between the bars x value and the x value plus the bar’s width. Something like:

  cont, _ = p_bar.contains(event)
  if not cont:
    is_small_bar = event.xdata >= p_bar.get_x() and event.xdata < p_bar.get_x()+p_bar.get_width() and event.y <= 100
  if cont or is_small_bar:
    b_ht = p_bar.get_height()

While working on the above I had an issue or two. One was generating an error message when I clicked outside the axes of the chart proper. I enabled some debug messages to see what was going on.

Rectangle(xy=(-0.166667, 0), width=0.333333, height=1771.44, angle=0)
motion_notify_event: xy=(1114, 18) xydata=(None, None) button=None dblclick=False inaxes=None
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 85, in callback
    is_small_bar = (event.xdata >= p_bar.get_x()) and (event.xdata < (p_bar.get_x() + p_bar.get_width())) and (event.y <= 100)
TypeError: '<=' not supported between instances of 'float' and 'NoneType'

Turns out that was easy enough to fix. Just needed to add a check on event.inaxes. And, a couple of times while editing I used event.x instead of event.xdata. So my test for a small bar was failing when I expected it to be true. And, I also realized that I was drawing to the chart unnecessarily while moving the mouse about none bar areas. So I added another check, only removing the annotation if it was visible. The check is much cheaper than continually updating the chart for no reason whatsoever. So, my cb_create() now looks like this.

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):
    is_small_bar = False
    is_visible = annot.get_visible()

    for grp_cont in bar_cont:
      for p_bar in grp_cont:
        if event.inaxes == ax:
          cont, _ = p_bar.contains(event)
          if not cont:
            is_small_bar = (event.xdata >= p_bar.get_x()) and (event.xdata < (p_bar.get_x() + p_bar.get_width())) and (event.y <= 100)
          if cont or is_small_bar:
            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:
            # don't want to draw to chart unless necessary
            if is_visible:
              annot.set_visible(False)
              fig.canvas.draw_idle()

  return callback 

Detailed Annotation

I really think the annotation pop-up should tell everything that is reasonable to tell. The user should not have to look around the chart to get additional contextual information. I know that’s what I’d like to get. There should be no need to look down to the x axis to see what age group the bar is showing. Nor to look over at the legend to determine the country or year represented by the bar currently being hovered over by the mouse. Nor to recall whether the chart is showing population counts or perecentages of the annual total. Just seems to make sense to me.

But, that is going to require more information being made available to the callback and a way to get at the correct pieces.

Each chart type is more or less about one thing: Type 1 a country, Type 2 a year and Type 3 an age group. So, I will pass that info in as a new parameter, plot_for. Then we need a list of the items (countries or years) in each group of bars — i.e. the items in the chart legend. I called the parameter lgnds, it is a list. And finally the labels for the x-axis, x_lbls — also a list. For Type 1 and 2 chart these labels are the age groups. For Type 3 charts they are years.

I am also going to add two counters, i and j which I will use to determine the index to each of the two new list parameters for the column being hovered over. I thought about changing the for loops, but this just seemed easier.

For testing during development, I have just been calling chart.py directly in a terminal window. For the final tests I will use the charting menu system.

While testing the percentages, I noticed that some of the values were rather long floats. So, I modified the text to only display 3 decimal values.

Seems to work, more or less. My code now looks as follows.

def cb_create(ax, fig, bar_cont, plot_for, lgnds, x_lbls):
  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):
    is_small_bar = False
    is_visible = annot.get_visible()
    
    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 not cont:
            is_small_bar = (event.xdata >= p_bar.get_x()) and (event.xdata < (p_bar.get_x() + p_bar.get_width())) and (event.y <= 100)
          if cont or is_small_bar:
            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"{plot_for}: {lgnds[i]}\n{x_lbls[j]}\n{b_ht:.3f}"
            if do_percent:
              text += " %"
            else:
              text += " (000s)"
            annot.set_text(text)
            annot.set_visible(True)
            fig.canvas.draw_idle()
            return
          else:
            # don't want to draw to chart unless necessary
            if is_visible:
              annot.set_visible(False)
              fig.canvas.draw_idle()
        j += 1
      i += 1
  return callback 

## in plot_1ma()
  # event handling
  onmover = cb_create(ax, fig, c_rects, p_nms[0], list(p_years[0].keys()), x_lbls)
  cid = fig.canvas.mpl_connect('motion_notify_event', onmover)

## in plot_m1a()
  # event handling
  onmover = cb_create(ax, fig, c_rects, p_years[0], list(p_nms[0].keys()), x_lbls)
  cid = fig.canvas.mpl_connect('motion_notify_event', onmover)

## in plot_mm1()
  # event handling
  onmover = cb_create(ax, fig, c_rects, p_grps[0], list(p_nms[0].keys()), x_lbls)
  cid = fig.canvas.mpl_connect('motion_notify_event', onmover)

You got that list() thing, right. .keys() returns an iterable, not a list. I want to use an index to get a specific value from that iterable. So, I needed to convert it into a list before providing it to cb_create().

Fix Annotation Display when Overlapping Other Chart Elements

Bit of a problem with the readability of the annotation when it overlaps other chart elements. E.G. the legend or the title. Seems the annotation was being displayed under these elements. Turns out, like CSS, we have a zorder value we can use to make sure the annotation is displayed on top of everything else. Not really sure how big the value has to be; but, I decided let’s make it big and not worry about it too much. I used zorder=99.9.

There is also the alpha value for the box. I increased this to 0.9. Though I may yet make it 1.0. But 0.9 was definitely readable in my few tests. I also decided to change the background colour to black and specify the text colour as white.

I was able to make those changes in the call to annotate() in cb_create().

  annot = ax.annotate(f"", xy=(0,0), xytext=(-5,20), textcoords="offset points", color='white', zorder=99.9,
                    bbox=dict(boxstyle="round", fc="black", ec="b", lw=2, alpha=0.8),
                    arrowprops=dict(arrowstyle="->"))

Done

I think everything pretty much works in a way I like. So going to call this one done. Just have to commit my last set of changes.

Now, I really don’t know where I am going to go next. I do think this plotting thing is pretty much done — at least for now.

I am also thinking my next post will be on my originally intended weekly schedule. That is, next Monday, October 26th.

Until then, have fun and be safe.

Another Short Step Sideways

When starting on the draft for the post for next Monday, I found I needed to alter the code for the annotations. I wanted to get an image of a chart with more than one annotation present so that I could compare the value of two different bars. The current code using a mouseover just did not work. So, added some new functions and event monitoring code to show multiple annotations at one time in Type 1 charts, plot_1ma(). Have a post covering the changes ready for this coming Thursday. Will hopefully have the trouble causing post ready for a week today.

Resources