We now have a good background for some practical work with matplotlib events. We are now ready to explore the nitty-gritty. Let's begin with the list of events that matplotlib supports:
Event name |
Class |
Description |
---|---|---|
|
|
The mouse button is pressed |
|
|
The mouse button is released |
|
|
The canvas draw occurs |
|
|
A key is pressed |
|
|
A key is released |
|
|
Motion of the mouse |
|
|
An object in the canvas is selected |
|
|
The figure canvas is resized |
|
|
The scroll wheel of the mouse is rolled |
|
|
The mouse enters a figure |
|
|
The mouse leaves a figure |
|
|
The mouse enters an axes object |
|
|
The mouse leaves an axes object |
Note that the classes listed in the preceding events table are defined in matplotlib.backend_bases
.
The perceptual field of matplotlib is the canvas object—this is where the events take place, and this is the object that provides the interface to connect the code to the events. The canvas object has an mpl_connect
method, which must be called if you want to provide custom user interaction features along with your plots. This method just takes the following two arguments:
Event Name
column of the preceding tableWe will demonstrate some ways to use mpl_connect
in the subsequent examples.
In matplotlib, the mouse events may be any of the following:
The notebook for this chapter has a section on mouse events, which shows several examples. We will take a look at one of these in particular; it demonstrates the press and release events, and it does so with an almost tactile feedback mechanism. These are the tasks that it does:
Let's take a look at the code so that you can clearly see how to implement each of the aforementioned things:
In [8]: class Callbacks: def __init__(self): (figure, axes) = plt.subplots() axes.set_aspect(1) figure.canvas.mpl_connect( 'button_press_event', self.press) figure.canvas.mpl_connect( 'button_release_event', self.release) def start(self): plt.show() def press(self, event): self.start_time = time.time() def release(self, event): self.end_time = time.time() self.draw_click(event) def draw_click(self, event): size = 4 * (self.end_time - self.start_time) ** 2 c1 = plt.Circle( [event.xdata, event.ydata], 0.002,) c2 = plt.Circle( [event.xdata, event.ydata], 0.02 * size, alpha=0.2) event.canvas.figure.gca().add_artist(c1) event.canvas.figure.gca().add_artist(c2) event.canvas.figure.show() cbs = Callbacks() cbs.start()
Here, we saw the aforementioned mpl_connect
method in action. The constructor in our class sets up the figure's canvas instance with two callbacks. One will be fired when the canvas detects button_press_event
, and the other will be fired when the canvas detects button_release_event
.
When you run the preceding code in the notebook and click on the resulting graph, you will see something like this (depending on where you click and how long you hold down the mouse button):
There are a few things worth highlighting here:
mpl_connect
method to register callbacks, as previously discussedKeyboard events are similar to the mouse events, though there are only two of them:
key_press_event
key_release_event
These are used in the same way as the corresponding mouse button events, though they provide an interesting opportunity for the ergonomically sensitive user.
As of matplotlib version 1.4 and IPython version 2.3, keyboard events for plots are not supported in the nbagg
backend. Therefore, we will use IPython from the terminal for this section.
In the following section, we're going to take a look at an example that may give you some ideas with regard to the customization of your own plots. We're going to make a simple data viewer. Before we look at the code, let's describe what we want to do:
This is pretty straightforward, and it an excellent use case for keyboard events. We'll have to build some pieces for this, though. Here's what we will need:
In a terminal IPython session, we can perform the initial imports and run the color map setup in the following way:
In [1]: import numpy as np In [2]: import matplotlib as mpl In [3]: from matplotlib import pyplot as plt In [4]: import seaborn as sns In [5]: pallete_name = "husl" In [6]: colors = sns.color_palette(pallete_name, 8) In [7]: colors.reverse() In [8]: cmap = mpl.colors.LinearSegmentedColormap.from_list( pallete_name, colors)
Next, we need a function that can act as a given dataset as well as another function that will act as the very large dataset (infinite, in this case). The second function is a good fit if you wish to use a generator:
In [9]: def make_data(n, c): r = 4 * c * np.random.rand(n) ** 2 theta = 2 * np.pi * np.random.rand(n) area = 200 * r**2 * np.random.rand(n) return (r, area, theta) In [10]: def generate_data(n, c): while True: yield make_data(n, c)
We've now received an endless number of datasets that can be used in our viewer. Now, let's create the data Carousel
class. It's needs to perform the following tasks:
key_press_event
and then dispatch to the other functions, depending on the pressed keyHere's what the data Carousel
class looks like:
In [13]: class Carousel: def __init__(self, data): (self.left, self.right) = ([], []) self.gen = data self.last_key = None def start(self, axes): make_plot(*self.next(), axes=axes) def prev(self): if not self.left: return [] data = self.left.pop() self.right.insert(0, data) return data def next(self): if self.right: data = self.right.pop(0) else: data = next(self.gen) self.left.append(data) return data def reset(self): self.right = self.left + self.right self.left = [] def dispatch(self, event): if event.key == "right": self.handle_right(event) elif event.key == "left": self.handle_left(event) elif event.key == "r": self.handle_reset(event) def handle_right(self, event): print("Got right key ...") if self.last_key == "left": self.next() update_plot(*self.next(), event=event) self.last_key = event.key def handle_left(self, event): print("Got left key ...") if self.last_key == "right": self.prev() data = self.prev() if data: update_plot(*data, event=event) self.last_key = event.key def handle_reset(self, event): print("Got reset key ...") self.reset() update_plot(*self.next(), event=event) self.last_key = event.key In [14]: %paste
The class that will perform the setup and configuration duties, including the connecting of key_press_event
to the callback and the instantiating of the Carousel
object, is as follows:
In [15]: class CarouselManager: def __init__(self, density=300, multiplier=1): (figure, self.axes) = plt.subplots( figsize=(12,12), subplot_kw={"polar": "True"}) self.axes.hold(False) data = generate_data(density, multiplier) self.carousel = Carousel(data) _ = figure.canvas.mpl_connect( 'key_press_event', self.carousel.dispatch) def start(self): self.carousel.start(self.axes) plt.show() In [16]: %paste
And with this, we're ready to navigate through our data visually. We'll make the displayed circles larger than the default size by setting the multiplier to a higher number in the following way:
In [17]: cm = CarouselManager(multiplier=2) In [18]: cm.start()
This will make a GUI window pop up on the screen with an image that looks like this:
Now try pressing the right and left arrows of your keyboard to view the different (randomly generated) datasets. As coded in the Carousel
class, pressing R will return us to the beginning of the data.
The most practical understanding that you can take away, which you can use in your own projects, is that you need to write a dispatch method or function for every key (or key combination) press and release event. The simplicity of the matplotlib interface of providing a single key press event for all the keys ensures that the matplotlib code stays compact while keeping the options open for the developers. The flip side is that you need to implement the individual key handling yourself for whichever keys your particular use case has a need of.
We can execute callbacks when the mouse enters and leaves figures or axes in a way that is similar to the connection of the keyboard and mouse events. This can be helpful if you have complex plots with many subplots. It will allow you to provide a visual feedback to the user regarding the subplot that is our focus or even expose a larger view of the focused plot. The IPython Notebook for this chapter covers an example of this.
The next event that we will mention is a special one—the event of an object being picked. Object picking is one of the greatest, although unsung, features of matplotlib as it allows one to essentially create a custom data browser that is capable of revealing the details in the deeply nested or rich data across large scales.
Every Artist
instance (naturally including subclasses of Artist
) has an attribute called picker
. The setting of this attribute is what enables object picking in matplotlib.
The definition of picked can vary, depending on the context. For instance, setting Artist.picker
can have the following results:
True
, picking is enabled for the artist object and pick_event
will be fired every time a mouse event occurs over the artist object in the figure.float
or int
), the value is interpreted as a tolerance. If the event's data (such as the x and y values) is within the value of this tolerance, pick_event
will be fired.pick_event
is fired.None
, picking is disabled.The object picking feature provides the means by which a programmer can create dynamic views on data. This feature is rivaled only by expensive proprietary software. Thanks to this capability, in conjunction with matplotlib's custom styles, one can easily create beautiful, compelling data visualization applications that are tailored for the needs of the user.
The IPython Notebook in this chapter covers an example of this, and it should serve as a source of inspiration for a great number of use cases.
This section discusses the combining of multiple events or other sources of data in order to provide a more highly customized user experience for visual plot updates, the preparation of data, the setting of object properties, or the updating of widgets. The multiple events or decisions that are made based on multiple or cascading events is what we will refer to as compound events.
The first example of compound events that we will touch upon are those managed by a backend widget for interactive navigation. This widget is available for all backends (including the nbagg
backend for IPython when it is not in the inline
mode). The navigation toolbar widget has multiple buttons, each with a specific function. In brief, the functionality associated with each button is as follows:
Furthermore, when a toolbar action is engaged, the toolbar
instance sets the toolbar's current mode. For instance, when the Zoom-to-Rectangle button is clicked, the mode will be set to zoom rect. When in Pan/Zoom, the mode will be set to pan/zoom
. These can be used in conjunction with the supported events to fire callbacks in response to the toolbar activity.
As a matter of fact, the matplotlib.backend_bases.NavigationToolbar2
toolbar class is an excellent place to look for examples of compound events. Let's examine the Pan/Zoom button. The class tracks the following via the attributes that can be set:
press
eventrelease
eventmouse move
event (this is correlated to a mouse drag later in the code)toolbar
modezoom
modeDuring the toolbar setup, the toolbar button events are connected to callbacks. When these buttons are pressed and the callbacks are fired, old events are disconnected and the new ones are connected. In this way, a chain of events may be set up with a particular sequence of events firing only a particular set of callbacks in a particular order.
The code in NavigationToolbar2
is a great starting place if you want some ideas on how you can combine events in your own projects. You can have a workflow that requires responses to plot updates only if a series of other events have taken place first. You can accomplish this by connecting the events to and disconnecting them from various callbacks.
Let's utilize a combination of the toolbar
mode and a mouse button release for a practical example that demonstrates the creation of a compound event.
The problem that we want to address is this—when a user pans or zooms out of the range of the previously computed data in a plotted area, the user is presented with parts of an empty grid with no visualization in the newly exposed area. It would be nice if we could put our newfound event callback skills to use in order to solve this issue.
One possible example where it would be useful to refresh the plot figure when it has been panned is a plot for a topographic map. We're going to do a few things with this example:
cmap
(matplotlib's color map) method to give the altitudes the look of a physical mapThe custom color map and the equations to generate a topographical map have been saved to ./lib/topo.py
in this chapter's IPython Notebook repository. We imported this module at the beginning of the notebook. So it's ready to use. The first class that we will define is TopoFlowMap
, a wrapper class that will be used to update the plot when we pan:
In [21]: class TopoFlowMap: def __init__(self, xrange=None, yrange=None, seed=1): self.xrange = xrange or (0,1) self.yrange = yrange or (0,1) self.seed = seed (self.figure, self.axes) = plt.subplots( figsize=(12,8)) self.axes.set_aspect(1) self.colorbar = None self.update() def get_ranges(self, xrange, yrange): if xrange: self.xrange = xrange if yrange: self.yrange = yrange return (xrange, yrange) def get_colorbar_axes(self): colorbar_axes = None if self.colorbar: colorbar_axes = self.colorbar.ax colorbar_axes.clear() return colorbar_axes def get_filled_contours(self, coords): return self.axes.contourf( cmap=topo.land_cmap, *coords.values()) def update_contour_lines(self, filled_contours): contours = self.axes.contour( filled_contours, colors="black", linewidths=2) self.axes.clabel( contours, fmt="%d", colors="#330000") def update_water_flow(self, coords, gradient): self.axes.streamplot( coords.get("x")[:,0], coords.get("y")[0,:], gradient.get("dx"), gradient.get("dy"), color="0.6", density=1, arrowsize=2) def update_labels(self): self.colorbar.set_label("Altitude (m)") self.axes.set_title( "Water Flow across Land Gradients", fontsize=20) self.axes.set_xlabel("$x$ (km)") self.axes.set_ylabel("$y$ (km)") def update(self, xrange=None, yrange=None): (xrange, yrange) = self.get_ranges(xrange, yrange) (coords, grad) = topo.make_land_map( self.xrange, self.yrange, self.seed) self.axes.clear() colorbar_axes = self.get_colorbar_axes() filled_contours = self.get_filled_contours(coords) self.update_contour_lines(filled_contours) self.update_water_flow(coords, grad) self.colorbar = self.figure.colorbar( filled_contours, cax=colorbar_axes) self.update_labels()
The notebook returns to the IPython backend to display the graph (previously using a different backend for other examples):
In [22]: plt.switch_backend('nbAgg') In [23]: tfm = TopoFlowMap( xrange=(0,1.5), yrange=(0,1.5), seed=1732) plt.show()
The preceding code gives us the following plot:
If you click on the Pan/Zoom button in the navigation toolbar and then drag the plotted data about, you will see that the empty grid contains the data that hasn't been plotted (the area that was outside the axes prior to the panning action).
Since we do want to redraw and there is no pan event to connect to, what are our options? Well, two come to mind:
draw_event
, which fires each time the canvas is movedbutton_release_event
, which will fire when the panning is completeIf our figure was easy to draw with simple equations, the first option would probably be fine. However, we're performing some multivariate calculus on our simulated topography. As you might have noticed, our plot does not render immediately. So, let's go with the second option.
To make our lives easier, we will take advantage of the mode attribute of NavigationTool2
, which will let us know when one of the events that we care about, pan/zoom
, has taken place.
Here's the manager class for the plot-refresh feature:
In [24]: class TopoFlowMapManager: def __init__(self, xrange=None, yrange=None, seed=1): self.map = TopoFlowMap(xrange, yrange, seed) _ = self.map.figure.canvas.mpl_connect( 'button_release_event', self.handle_pan_zoom_release) def start(self): plt.show() def handle_pan_zoom_release(self, event): if event.canvas.toolbar.mode != "pan/zoom": return self.map.update(event.inaxes.get_xlim(), event.inaxes.get_ylim()) event.canvas.draw()
As with the other examples, we used the constructor to set up the event callback for a mouse button release. The callback will be fired for every button click. However, the code will not execute past the conditional if we are not in the pan/zoom
mode. In our case, the callback does two crucial things for our feature:
update
method on the TopoFlowMap
instance, which changes the range for the NumPy mgrid
function and recalculates the gradients for this new rangeYou can test this out with the following code:
In [25]: tfmm = TopoFlowMapManager( xrange=(0,1.5), yrange=(0,1.5), seed=1732) tfmm.start()
This particular plot is fairly involved, and hence, it is potentially similar to some real-world examples that you may come across. However, keep in mind that if you have simple data to display with little or no calculation, firing your callback on draw_event
instead of button_release_event
will render the update as you move the mouse.