Interactive point identification

I find it often quite useful to be able to identify points within a plot simply by clicking. This recipe provides a fairly simple functor that can be connected to any plot. I've used it with both scatter and standard plots.

Because often you'll have multiple views of a dataset spread across either multiple figures, or at least multiple axis, I've also provided a utility to link these plots together so clicking on a point in one plot will highlight and identify that data point on all other linked plots.

   1 import math
   2 
   3 import pylab
   4 import matplotlib
   5 
   6 
   7 class AnnoteFinder:
   8   """
   9   callback for matplotlib to display an annotation when points are clicked on.  The
  10   point which is closest to the click and within xtol and ytol is identified.
  11     
  12   Register this function like this:
  13     
  14   scatter(xdata, ydata)
  15   af = AnnoteFinder(xdata, ydata, annotes)
  16   connect('button_press_event', af)
  17   """
  18 
  19   def __init__(self, xdata, ydata, annotes, axis=None, xtol=None, ytol=None):
  20     self.data = zip(xdata, ydata, annotes)
  21     if xtol is None:
  22       xtol = ((max(xdata) - min(xdata))/float(len(xdata)))/2
  23     if ytol is None:
  24       ytol = ((max(ydata) - min(ydata))/float(len(ydata)))/2
  25     self.xtol = xtol
  26     self.ytol = ytol
  27     if axis is None:
  28       self.axis = pylab.gca()
  29     else:
  30       self.axis= axis
  31     self.drawnAnnotations = {}
  32     self.links = []
  33 
  34   def distance(self, x1, x2, y1, y2):
  35     """
  36     return the distance between two points
  37     """
  38     return math.hypot(x1 - x2, y1 - y2)
  39 
  40   def __call__(self, event):
  41     if event.inaxes:
  42       clickX = event.xdata
  43       clickY = event.ydata
  44       if self.axis is None or self.axis==event.inaxes:
  45         annotes = []
  46         for x,y,a in self.data:
  47           if  clickX-self.xtol < x < clickX+self.xtol and  clickY-self.ytol < y < clickY+self.ytol :
  48             annotes.append((self.distance(x,clickX,y,clickY),x,y, a) )
  49         if annotes:
  50           annotes.sort()
  51           distance, x, y, annote = annotes[0]
  52           self.drawAnnote(event.inaxes, x, y, annote)
  53           for l in self.links:
  54             l.drawSpecificAnnote(annote)
  55 
  56   def drawAnnote(self, axis, x, y, annote):
  57     """
  58     Draw the annotation on the plot
  59     """
  60     if (x,y) in self.drawnAnnotations:
  61       markers = self.drawnAnnotations[(x,y)]
  62       for m in markers:
  63         m.set_visible(not m.get_visible())
  64       self.axis.figure.canvas.draw()
  65     else:
  66       t = axis.text(x,y, "(%3.2f, %3.2f) - %s"%(x,y,annote), )
  67       m = axis.scatter([x],[y], marker='d', c='r', zorder=100)
  68       self.drawnAnnotations[(x,y)] =(t,m)
  69       self.axis.figure.canvas.draw()
  70 
  71   def drawSpecificAnnote(self, annote):
  72     annotesToDraw = [(x,y,a) for x,y,a in self.data if a==annote]
  73     for x,y,a in annotesToDraw:
  74       self.drawAnnote(self.axis, x, y, a)

To use this functor you can simply do something like this:

   1 x = range(10)
   2 y = range(10)
   3 annotes = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
   4 
   5 scatter(x,y)
   6 af =  AnnoteFinder(x,y, annotes)
   7 connect('button_press_event', af)

This is fairly useful, but sometimes you'll have multiple views of a dataset and it is useful to click and identify a point in one plot and find it in another. The below code demonstrates this linkage and should work between multiple axis or figures.

   1 def linkAnnotationFinders(afs):
   2   for i in range(len(afs)):
   3     allButSelfAfs = afs[:i]+afs[i+1:]
   4     afs[i].links.extend(allButSelfAfs)
   5 
   6 subplot(121)
   7 scatter(x,y)
   8 af1 = AnnoteFinder(x,y, annotes)
   9 connect('button_press_event', af1)
  10 
  11 subplot(122)
  12 scatter(x,y)
  13 af2 = AnnoteFinder(x,y, annotes)
  14 connect('button_press_event', af2)
  15 
  16 linkAnnotationFinders([af1, af2])

I find this fairly useful. By subclassing and redefining drawAnnote this simple framework could be used to drive a more sophisticated user interface.

Currently this implementation is a little slow when the number of datapoints becomes large. I'm particularly interested in suggestions people might have for making this faster and better.


Handling click events while zoomed

Often, you don't want to respond to click events while zooming or panning (selected using the toolbar mode buttons). You can avoid responding to those events by checking the mode attribute of the toolbar instance. The first example below shows how to do this using the pylab interface.

   1 from pylab import *
   2 
   3 def click(event):
   4    """If the left mouse button is pressed: draw a little square. """
   5    tb = get_current_fig_manager().toolbar
   6    if event.button==1 and event.inaxes and tb.mode == '':
   7        x,y = event.xdata,event.ydata
   8        plot([x],[y],'rs')
   9        draw()
  10 
  11 plot((arange(100)/99.0)**3)
  12 gca().set_autoscale_on(False)
  13 connect('button_press_event',click)
  14 show()

If your application is in an wxPython window, then chances are you created a handle to the toolbar during setup, as shown in the add_toolbar method of the embedding_in_wx2.py example script, and can then access the mode attribute of that object (self.toolbar.mode in that case) in your click handling method.


CategoryCookbookMatplotlib

Cookbook/Matplotlib/Interactive Plotting (last edited 2010-01-17 07:50:52 by newacct)