Event Handling and Interactive Plots

In the following sections of this IPython Notebook we be looking at the following:

  • matplotlib's event loop support
  • Basic Event Handling
    • List of supported events
    • Mouse events
    • Limitations of the IPython Notebook backend
    • Keyboard events
    • Axes and Figures events
    • Object picking
  • Compound Event Handling
    • Toolbar
    • Interactive panning and zooming of figures

Warm-up proceedures:


In [1]:
import matplotlib
matplotlib.use('nbagg')

Notice that we've left out the following line from our usual notebook prelude:

%matplotlib inline

We've disabled inline so that we get access to the interactive mode. More on that later :-)

Let's continue with the necessary imports:


In [2]:
import random
import sys
import time
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import Image
from typecheck import typecheck

sys.path.append("../lib")
import topo


---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
<ipython-input-2-c44eeade1318> in <module>()
     10 
     11 sys.path.append("../lib")
---> 12 import topo

ImportError: No module named topo

Let's set up our colors for this notebook:


In [3]:
pallete_name = "husl"
#colors = sns.color_palette(pallete_name, 8)
#colors.reverse()
#cmap = mpl.colors.LinearSegmentedColormap.from_list(pallete_name, colors) 
cmap = mpl.colors.Colormap('Sequential')

Event Loop Basics

Before we look at matplotlib's event loop support, let's do a quick survey of event loops and get a refresher on how they work. Here's a pretty simple "event" loop:

while True:
    pass

That loop is not going be worth our while to execute in this notebook :-) So let's do another one, almost as simple, that has a good chance of exiting in under a minute:


In [4]:
x = True
while x:
    time.sleep(1)
    if random.random() < 0.15:
        x = False

This loop only handles one "event": the change of a value from True to False. That loop will continue to run until the condition for a false value of x is met (a random float under a particular threshold).

So what relation do these simple loops have with the loops that power toolkits like GTK and Qt or frameworks like Twisted and Tornado? Usually event systems have something like the following:

  • a way to start the event loop
  • a way to stop the event loop
  • providing a means for registering events
  • providing a means for responding to events

During each run, a loop will usually check a data structure to see if there are any new events that have occurred since the last time it looped. In a network event system, each loop might check to see if any file descriptors are ready for reading or writing. In a GUI toolkit, each look might check to see if any clicks or button presses had occurred.

Given the simple criteria above, let's try building a minimally demonstrative, if not useful, event loop. To keep this small, we're not going to integrate with socket or GUI events. The event that our loop will respond to will be quite minimal indeed.


In [5]:
class EventLoop:
    def __init__(self):
        self.command = None
        self.status = None
        self.handlers = {"interrupt": self.handle_interrupt}
        self.resolution = 0.1

    def loop(self):
        self.command = "loop"
        while self.command != "stop":
            self.status = "running"
            time.sleep(self.resolution)
            
    def start(self):
        self.command = "run"
        try:
            self.loop()
        except KeyboardInterrupt:
            self.handle_event("interrupt")
            
    def stop(self):
        self.command = "stop"

    @typecheck
    def add_handler(self, fn: callable, event: str):
        self.handlers[event] = fn

    @typecheck
    def handle_event(self, event: str):
        self.handlers[event]()
        
    def handle_interrupt(self):
        print("Stopping event loop ...")
        self.stop()


---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-5-d0d6cdab5741> in <module>()
----> 1 class EventLoop:
      2     def __init__(self):
      3         self.command = None
      4         self.status = None
      5         self.handlers = {"interrupt": self.handle_interrupt}

<ipython-input-5-d0d6cdab5741> in EventLoop()
     22         self.command = "stop"
     23 
---> 24     @typecheck
     25     def add_handler(self, fn: callable, event: str):
     26         self.handlers[event] = fn

NameError: name 'typecheck' is not defined

Here's what we did:

  • Created a class that maintains a data structure for event handlers
  • We also added a default handler for the "interrupt" event
  • Created a loop method
  • Created methods for starting and stopping the loop (via an attribute change)
  • In our start method, we check for an interrupt signal, and fire off an interrupt handler for said signal
  • Created a method for adding event handlers to the handler data structure (should we want to add more)

Let's creat an instance and start it up:


In [ ]:
el = EventLoop()
el.start()

When you evaluate that cell, IPython will display the usual indicator that a cell is continuing to run:

In [*]:

As soon as you're satisfied that the loop is merrily looping, go up to the IPython Notebook menu and select "Kernel" -> "Interrupt". The cell with the loop in it should finish, with not only an In number instead of an asterisk, but our interrupt handler should have printed out a status message as well.

Though this event loop is fairly different from those that power networking libraries or GUI toolkits, it's very close (both in nature and code) to the default event loops matplotlib provides for its canvas objects. As such, this is a perfect starting place for your deeper understanding of matplotlib. To continue in this vein, reading the matplotlib backend source code would serve you well.

Standard Event Handling in matplotlib

With some event loop knowledge under our belts, we're ready to start working with matplotlib events.

Below is the list of supported events in matplotlib as of version 1.4:

Event name Class and description
button_press_event MouseEvent - mouse button is pressed
button_release_event MouseEvent - mouse button is released
draw_event DrawEvent - canvas draw
key_press_event KeyEvent - key is pressed
key_release_event KeyEvent - key is released
motion_notify_event MouseEvent - mouse motion
pick_event PickEvent - an object in the canvas is selected
resize_event ResizeEvent - figure canvas is resized
scroll_event MouseEvent - mouse scroll wheel is rolled
figure_enter_event LocationEvent - mouse enters a new figure
figure_leave_event LocationEvent - mouse leaves a figure
axes_enter_event LocationEvent - mouse enters a new axes
axes_leave_event LocationEvent - mouse leaves an axes

We'll discuss some of these below in more detail. With that information in hand, you should be able to tackle problems with any of the supported events in matplotlib.

Mouse Events

In the next cell, we will define a couple of callback functions, and then connet these to specific canvas events.

Go ahead and render the cell then click on the display plot a couple of times:


In [ ]:
def press_callback(event):
    event.canvas.figure.text(event.xdata, event.ydata, '<- clicked here')
    
def release_callback(event):
    event.canvas.figure.show()
    
(figure, axes) = plt.subplots()
press_conn_id = figure.canvas.mpl_connect('button_press_event', press_callback)
release_conn_id = figure.canvas.mpl_connect('button_release_event', release_callback)
plt.show()

Our callbacks display a little note close to each $(x, y)$ coordinate where we clicked (the location is not exact due to font-sizing, etc.) If we use a graphical indication as opposed to a textual one, we can get much better precision:


In [ ]:
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()

As you can see, we changed the callback to display a cicle instead of text. If you choose to press and hold, and then release a bit later, you will see that a second, transparent circle is displayed. The longer you hold, the larger the second transpent circle will be.

Let's try something a little more involved, adapted from the line-drawing example in the "Event handling and picking" chapter of the matplotlib Advanced Guide:


In [ ]:
class LineBuilder:
    def __init__(self, event_name='button_press_event'):
        (self.figure, self.axes) = plt.subplots()
        plt.xlim([0, 10])
        plt.ylim([0, 10])
        (self.xs, self.ys) = ([5], [5])
        (self.line,) = self.axes.plot(self.xs, self.ys)
        self.axes.set_title('Click the canvas to build line segments...')
        self.canvas = self.figure.canvas
        self.conn_id = self.canvas.mpl_connect(event_name, self.callback)

    def start(self):
        plt.show()

    def update_line(self, event):
        self.xs.append(event.xdata)
        self.ys.append(event.ydata)
        self.line.set_data(self.xs, self.ys)

    def callback(self, event):
        if event.inaxes != self.line.axes:
            return
        self.update_line(event)
        self.canvas.draw()

lb = LineBuilder()
lb.start()

For dessert, here's the slider demo from matplotlib:


In [ ]:
from matplotlib import widgets
from matplotlib.backend_bases import MouseEvent

def get_sine_data(amplitude=5, frequency=3, time=None):
    return amplitude * np.sin(2 * np.pi * frequency * time)

class SineSliders:
    def __init__(self, amplitude=5, frequency=3):
        (self.figure, _) = plt.subplots()
        self.configure()
        self.a0 = amplitude
        self.f0 = frequency
        self.time = np.arange(0.0, 1.0, 0.001)
        self.data = get_sine_data(
            amplitude=self.a0, frequency=self.f0, time=self.time)
        (self.line,) = plt.plot(self.time, self.data, lw=2, color='red')
        self.axes_amp  = plt.axes([0.25, 0.15, 0.65, 0.03])
        self.axes_freq = plt.axes([0.25, 0.1, 0.65, 0.03])
        self.setup_sliders()
        self.setup_reset_button()
        self.setup_color_selector()

    def start(self):
        plt.show()

    def configure(self):
        plt.subplots_adjust(left=0.25, bottom=0.25)
        plt.axis([0, 1, -10, 10])

    def setup_sliders(self):
        self.slider_amp = widgets.Slider(
            self.axes_amp, 'Amp', 0.1, 10.0, valinit=self.a0)
        self.slider_freq = widgets.Slider(
            self.axes_freq, 'Freq', 0.1, 30.0, valinit=self.f0)
        self.slider_freq.on_changed(self.update)
        self.slider_amp.on_changed(self.update)
        
    def setup_reset_button(self):
        reset_axes = plt.axes([0.8, 0.025, 0.1, 0.04])
        reset_button = widgets.Button(reset_axes, 'Reset', hovercolor='0.975')
        reset_button.on_clicked(self.reset)
        
    def setup_color_selector(self):
        radio_axes = plt.axes([0.025, 0.5, 0.15, 0.15], aspect=1)
        radio_select = widgets.RadioButtons(
            radio_axes, ('red', 'blue', 'green',), active=0)
        radio_select.on_clicked(self.switchcolor)
        
    def update(self, val):
        self.data = get_sine_data(self.slider_amp.val,
                                  self.slider_freq.val,
                                  self.time)
        self.line.set_ydata(self.data)
        self.figure.canvas.draw()

    def reset(self, event):
        self.slider_freq.reset()
        self.slider_amp.reset()

    def switchcolor(self, label):
        self.line.set_color(label)
        self.figure.canvas.draw()

sldrs = SineSliders(amplitude=0.5, frequency=20)
sldrs.start()

Limitations of nbagg

The IPython Notebook AGG backend currently doesn't provide support for the following matplotlib events:

  • key_press
  • scroll_event (mouse scrolling)
  • mouse right click
  • mouse doubleclick

Also, mouse movement events can be a little inconsistent (this can be especially true if your browser or other application is running at a significant CPU%, causing events to be missed in matplotlib running in an IPython notebook).

However, we can still use IPython while switching to a new backend for matplotlib. To see which backends are available to you:


In [ ]:
sorted(set(mpl.rcsetup.interactive_bk + mpl.rcsetup.non_interactive_bk + mpl.rcsetup.all_backends))

Currently keyboard events aren't supported by IPython and the matplotlib nbagg backend. So, for this section, we'll switch over to your default platform's GUI toolkit in matplotlib.

You have two options for the remainder of this notebook:

  1. Use IPython from a terminal, or
  2. Switch backends in this notebook.

For terminal use, change directory to where you cloned this notebook's git repo and then fire up IPython:

$ cd interaction
$ make repl

The repl target is a convenience that uses a Python virtual environment and the downloaded dependencies for this notebook. Once you're at the IPython prompt, you may start entering code with automatically-configured access to the libraries needed by this notebook.

If you would like to continue using this notebook instead of switching to the terminal, you'll need to change your backend for the remaining examples. For instance:


In [ ]:
plt.switch_backend('MacOSX')

Keyboard Events

Let's prepare for our key event explorations by defining some support functions ahead of time:


In [ ]:
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)

def generate_data(n, c):
    while True:
        yield make_data(n, c)
                 
def make_plot(radius, area, theta, axes=None):
    scatter = axes.scatter(
        theta, radius, c=theta, s=area, cmap=cmap)
    scatter.set_alpha(0.75)

def update_plot(radius, area, theta, event):
    figure = event.canvas.figure
    axes = figure.gca()
    make_plot(radius, area, theta, axes)
    event.canvas.draw()

Now let's make a class which will:

  • dispatch based upon keys pressed and
  • navigate through our endless data set

In [ ]:
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

One more class, to help keep things clean:


In [ ]:
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()

Now we can take it for a spin:


In [ ]:
cm = CarouselManager(multiplier=2)
cm.start()

In the GUI canvas, you should see something that looks a bit like this:

Image("figure_1.png")

The plot shoudl have the focus automatically. Press the right and left arrow keys to navigate through your data sets. You can return to the beginning of the data set by typing "r", the "reset" key. Play with it a bit, to convince yourself that it's really doing what we intended :-)

Axes and Figure Events


In [ ]:
def enter_axes(event):
    print('enter_axes', event.inaxes)
    event.inaxes.patch.set_facecolor('yellow')
    event.canvas.draw()

def leave_axes(event):
    print('leave_axes', event.inaxes)
    event.inaxes.patch.set_facecolor('white')
    event.canvas.draw()

def enter_figure(event):
    print('enter_figure', event.canvas.figure)
    event.canvas.figure.patch.set_facecolor('red')
    event.canvas.draw()

def leave_figure(event):
    print('leave_figure', event.canvas.figure)
    event.canvas.figure.patch.set_facecolor('grey')
    event.canvas.draw()

class FigureAndAxesFocus:
    def __init__(self):
        (self.figure, (self.axes1, self.axes2)) = plt.subplots(2, 1)
        title = "Hover mouse over figure or its axes to trigger events"
        self.figure.suptitle(title)
        self.setup_figure_events()
        self.setup_axes_events()

    def start(self):
        plt.show()
        
    def setup_figure_events(self):
        self.figure.canvas.mpl_connect(
            "figure_enter_event", enter_figure)
        self.figure.canvas.mpl_connect(
            "figure_leave_event", leave_figure)

    def setup_axes_events(self):
        self.figure.canvas.mpl_connect(
            "axes_enter_event", enter_axes)
        self.figure.canvas.mpl_connect(
            "axes_leave_event", leave_axes)

Let's try it out:


In [ ]:
faaf = FigureAndAxesFocus()
faaf.start()

Object Picking

The next event we will mention is a special one: the event of an object being "picked". Every Artist instance (naturally including any subclassess of Artist) has an attribute picker. Setting this attribute is what enables object picking in matplotlib.

The definition of picked can vary, depending upon context. For instance, setting Artist.picked has the following results:

  • If True, picking is enabled for the artist object and a pick_event will fire any time a mouse event occurs over the artist object in the figure.
  • If a number (e.g., float or int), the value is interpreted as a "tolerance"; if the event's data (such as $x$ and $y$ values) is within the value of that tolerance, the pick_event will fire.
  • If a callable, then the provided function or method returns a boolean value which determines if the pick_event is fired.
  • If None, picking is disabled.

The example below is adapted from the matplotlib project's picking exercise in the Advanced User's Guide. In it, we create a data set of 100 arrays, each containing 1000 random numbers. The sample mean and standard deviation of each is determined, and a plot is made of the 100 means vs the 100 standard deviations. We then connect the line created by the plot command to the pick event, and plot the original (randomly generated) time series data corresponding to the "picked" points. If more than one point is within the tolerance of the clicked on point, we display multiple subplots for the time series which fall into our tolerance (in this case, 10 pixels).


In [ ]:
class DataPicker:
    def __init__(self, range):
        self.range = range
        self.figure = self.axes = self.line = None
        self.xs = np.random.rand(*self.range)
        self.means = np.mean(self.xs, axis=1)
        self.stddev = np.std(self.xs, axis=1)

    def start(self):
        self.create_main_plot()
        self.figure.canvas.mpl_connect('pick_event', self.handle_pick)
        plt.show()

    def create_main_plot(self):
        (self.figure, self.axes) = plt.subplots()
        self.axes.set_title('click on point to plot time series')
        (self.line,) = self.axes.plot(self.means, self.stddev, 'o', picker=10)

    def create_popup_plot(self, n, event):
        popup_figure = plt.figure()
        for subplotnum, i in enumerate(event.ind):
            popup_axes = popup_figure.add_subplot(n, 1, subplotnum + 1)
            popup_axes.plot(self.xs[i])
            text_data = (self.means[i], self.stddev[i])
            popup_axes.text(
                0.05, 0.9,
                '$\mu$=%1.3f\n$\sigma$=%1.3f' % text_data,
                transform=popup_axes.transAxes, va='top')
            popup_axes.set_ylim(-0.5, 1.5)
        popup_figure.show()

    def handle_pick(self, event):
        if event.artist != self.line:
            return
        n = len(event.ind)
        if not n:
            return
        self.create_popup_plot(n, event)

In [ ]:
dp = DataPicker(range=(100,1000))
dp.start()

Compound Event Handling

This section discusses the combination of multiple events or other sources of data in order to provide a more highly customized user experience, whether that be for visual plot updates, preparation of data, setting object properties, or updating widgets. This is what we will refer to as "compound events".

matplotlib backends come with a feature we haven't discussed yet: a widget for interactive navigation. This widget is available for all the backends (including the nbagg backend for IPython, when not in "inline" mode). In brief, the functionality associated with the buttons in the widget is as follows:

  • Home: returns the figure to its originally rendered state
  • Previous: return to the previous view in the plot's history
  • Next: move to the next view in the plot's history
  • Pan/Zoom: pan across the plot by clicking and holding the left mouse button; zoom by clicking and holding the right mouse button (behavior differs between Cartesian and Polar plots)
  • Zoom-to-Rectangle: zoom in on a selected portion of the plot
  • Subplot Configuration: configure the display of subplots via a pop-up widget with various parameters
  • Save: save the plot, in its currently displayed state, to a file

When a toolbar action is engaged, the NavigationToolbar instance sets the 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 toolbar activity.

In point of fact, the toolbar class, matplotlib.backend_bases.NavigationToolbar2 is an excellent place to look for examples of "compound events". Let's examine the Pan/Zoom button. The class tracks the following via attributes that get set:

  • The connection id for a "press" event
  • The connection id for a "release" event
  • The connection id for a "mouse move" event (correlated to a mouse drag later)
  • Whether the toolbar is "active"
  • What the toolbar mode is
  • What the zoom mode is

During toolbar setup, toolbar button events are connected to callbacks. When these buttons are pressed, and the callbacks are fired, old events are disconnected and new ones connected. In this way, chains of events may be set up with a particular sequence of events firing only a particular set of callbacks and in a particular order.

Specialized Events

The code in matplotlib.backend_bases.NavigationToolbar2 is a great place to go to get some ideas about how you might combine events in your own projects. You might have a workflow that requires responses to plot updates, but only if a series of other events has taken place first. You can accomplish these by connecting events to and disconnecting them from various callbacks.

Interactive Panning and Zooming

Let's go back to the toolbar for a practical example of creating a compound event.

The problem we want to address is this: when a user pans or zooms out of the range of previously computed data in a plotted area, they are presented with parts of an empty grid with no visualization. It would be nice if we could put our new-found event callback skills to use in order to solve this issue.

Let's look at an example where it would be useful to have the plot figure refreshed when it is moved: a topographic map. Geophysicsist Joe Kington has provided some nice answers on Stackoverflow regarding matplotlib in the context of terrain gradients. In one particular example, he showed how to view the flow of water from random wells on a topographic map. We're going to do a couple of things with this example:

  • add a color map to give it the look of a physical map
  • give altitude in meters, and most importantly,
  • create a class that can update the map via a method call

Our custom color map and Joe's equations for generating a topographical map have been saved to ./lib/topo.py. We'll need to import those. Then we can define TopoFlowMap, our wrapper class that will be used to update the plot when we pan:


In [ ]:
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()

Let's switch back to the IPython Notebook backend, so we have a reference image saved in the notebook:


In [ ]:
plt.switch_backend('nbAgg')

Let's draw the topographical map next, without any ability to update when panning:


In [ ]:
tfm = TopoFlowMap(xrange=(0,1.5), yrange=(0,1.5), seed=1732)
plt.show()

If you click the "pan/zoom" button on the navigation toolbar, and then click+hold on the figure, you can move it about. Note that, when you do so, nothing gets redrawn.

Since we do want to redraw, and there is no "pan event" to connect to, what are our options? Well, two come to mind:

  • piggy back on the draw_event, which fires each time the canvas is moved, or
  • use the button_release_event which will fire when the panning is complete

If our figure was easy to draw with simple equations, the first option would probably be fine. However, we're doing 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.

There's an added bonus, though, that will make our lives easier: the NavigationTool2 keeps track of the mode it is in on it's mode attribute. Let's use that to save some coding!


In [ ]:
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()

Let's switch back to the native backend (in my case, that's MacOSX; you may need Qt5Agg, WXAgg, or GTK3Agg):


In [ ]:
plt.switch_backend('MacOSX')

Run the next bit of code, and then start panning around and releasing; you should see the new data displayed after the callbacks fires off the recalculation.


In [ ]:
tfmm = TopoFlowMapManager(xrange=(0,1.5), yrange=(0,1.5), seed=1732)
tfmm.start()

This is not a perfect topographical model, so sometimes you will see colors get shifted as the range of altitudes decreases or increases in a given view. Certainly close enough to demonstrate this use case, though :-)

Another thing you could do is add support for zoom rect such that the contour lines don't get angled (due to sparse data sample spacing), but stay smoothly curved no matter how far you zoom in. Given the example above, that should be fairly easy to implement, and we leave it as a fun exercise for the motivated reader :-)