Skip to content

Instantly share code, notes, and snippets.

@t20100
Created January 29, 2016 09:53
Show Gist options
  • Save t20100/e5a9ba1196101e618883 to your computer and use it in GitHub Desktop.
Save t20100/e5a9ba1196101e618883 to your computer and use it in GitHub Desktop.
Pan and zoom interaction over a matplotlib Figure
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# /*##########################################################################
#
# Copyright (c) 2016 European Synchrotron Radiation Facility
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# ###########################################################################*/
"""Pan and zoom interaction to plug on a matplotlib Figure.
Interaction:
- Zoom in/out with the mouse wheel
- Pan figures by dragging the mouse with left button pressed
- Select a zoom-in area by dragging the mouse with right button pressed
It provides a figure_pz function to create a Figure with interaction.
Example:
>>> import matplotlib.pyplot as plt
>>> from mpl_interaction import figure_pz
>>> fig = figure_pz()
>>> ax = fig.add_subplot(1, 1, 1)
>>> ax.plot((1, 2, 1))
>>> plt.show()
The :class:`PanAndZoom` class can be used to add interaction
to an existing Figure.
Example:
>>> import matplotlib.pyplot as plt
>>> from mpl_interaction import PanAndZoom
>>> fig = plt.figure()
>>> pan_zoom = PanAndZoom(fig) # Add support for pan and zoom
>>> ax = fig.add_subplot(1, 1, 1)
>>> ax.plot((1, 2, 1))
>>> plt.show()
Known limitations:
- Only support linear and log scale axes.
- Zoom area not working well with keep aspect ratio.
- Interfere with matplotlib toolbar.
"""
import logging
import math
import weakref
import numpy
import matplotlib.pyplot as _plt
class MplInteraction(object):
"""Base class for class providing interaction to a matplotlib Figure."""
def __init__(self, figure):
"""Initializer
:param Figure figure: The matplotlib figure to attach the behavior to.
"""
self._fig_ref = weakref.ref(figure)
self._cids = []
def __del__(self):
self.disconnect()
def _add_connection(self, event_name, callback):
"""Called to add a connection to an event of the figure
:param str event_name: The matplotlib event name to connect to.
:param callback: The callback to register to this event.
"""
cid = self.figure.canvas.mpl_connect(event_name, callback)
self._cids.append(cid)
def disconnect(self):
"""Disconnect interaction from Figure."""
if self._fig_ref is not None:
figure = self._fig_ref()
if figure is not None:
for cid in self._cids:
figure.canvas.mpl_disconnect(cid)
self._fig_ref = None
@property
def figure(self):
"""The Figure this interaction is connected to or
None if not connected."""
return self._fig_ref() if self._fig_ref is not None else None
def _axes_to_update(self, event):
"""Returns two sets of Axes to update according to event.
Takes care of multiple axes and shared axes.
:param MouseEvent event: Matplotlib event to consider
:return: Axes for which to update xlimits and ylimits
:rtype: 2-tuple of set (xaxes, yaxes)
"""
x_axes, y_axes = set(), set()
# Go through all axes to enable zoom for multiple axes subplots
for ax in self.figure.axes:
if ax.contains(event)[0]:
# For twin x axes, makes sure the zoom is applied once
shared_x_axes = set(ax.get_shared_x_axes().get_siblings(ax))
if x_axes.isdisjoint(shared_x_axes):
x_axes.add(ax)
# For twin y axes, makes sure the zoom is applied once
shared_y_axes = set(ax.get_shared_y_axes().get_siblings(ax))
if y_axes.isdisjoint(shared_y_axes):
y_axes.add(ax)
return x_axes, y_axes
def _draw(self):
"""Conveninent method to redraw the figure"""
self.figure.canvas.draw()
class ZoomOnWheel(MplInteraction):
"""Class providing zoom on wheel interaction to a matplotlib Figure.
Supports subplots, twin Axes and log scales.
"""
def __init__(self, figure=None, scale_factor=1.1):
"""Initializer
:param Figure figure: The matplotlib figure to attach the behavior to.
:param float scale_factor: The scale factor to apply on wheel event.
"""
super(ZoomOnWheel, self).__init__(figure)
self._add_connection('scroll_event', self._on_mouse_wheel)
self.scale_factor = scale_factor
@staticmethod
def _zoom_range(begin, end, center, scale_factor, scale):
"""Compute a 1D range zoomed around center.
:param float begin: The begin bound of the range.
:param float end: The end bound of the range.
:param float center: The center of the zoom (i.e., invariant point)
:param float scale_factor: The scale factor to apply.
:param str scale: The scale of the axis
:return: The zoomed range (min, max)
"""
if begin < end:
min_, max_ = begin, end
else:
min_, max_ = end, begin
if scale == 'linear':
old_min, old_max = min_, max_
elif scale == 'log':
old_min = numpy.log10(min_ if min_ > 0. else numpy.nextafter(0, 1))
center = numpy.log10(
center if center > 0. else numpy.nextafter(0, 1))
old_max = numpy.log10(max_) if max_ > 0. else 0.
else:
logging.warning(
'Zoom on wheel not implemented for scale "%s"' % scale)
return begin, end
offset = (center - old_min) / (old_max - old_min)
range_ = (old_max - old_min) / scale_factor
new_min = center - offset * range_
new_max = center + (1. - offset) * range_
if scale == 'log':
try:
new_min, new_max = 10. ** float(new_min), 10. ** float(new_max)
except OverflowError: # Limit case
new_min, new_max = min_, max_
if new_min <= 0. or new_max <= 0.: # Limit case
new_min, new_max = min_, max_
if begin < end:
return new_min, new_max
else:
return new_max, new_min
def _on_mouse_wheel(self, event):
if event.step > 0:
scale_factor = self.scale_factor
else:
scale_factor = 1. / self.scale_factor
# Go through all axes to enable zoom for multiple axes subplots
x_axes, y_axes = self._axes_to_update(event)
for ax in x_axes:
transform = ax.transData.inverted()
xdata, ydata = transform.transform_point((event.x, event.y))
xlim = ax.get_xlim()
xlim = self._zoom_range(xlim[0], xlim[1],
xdata, scale_factor,
ax.get_xscale())
ax.set_xlim(xlim)
for ax in y_axes:
ylim = ax.get_ylim()
ylim = self._zoom_range(ylim[0], ylim[1],
ydata, scale_factor,
ax.get_yscale())
ax.set_ylim(ylim)
if x_axes or y_axes:
self._draw()
class PanAndZoom(ZoomOnWheel):
"""Class providing pan & zoom interaction to a matplotlib Figure.
Left button for pan, right button for zoom area and zoom on wheel.
Support subplots, twin Axes and log scales.
"""
def __init__(self, figure=None, scale_factor=1.1):
"""Initializer
:param Figure figure: The matplotlib figure to attach the behavior to.
:param float scale_factor: The scale factor to apply on wheel event.
"""
super(PanAndZoom, self).__init__(figure, scale_factor)
self._add_connection('button_press_event', self._on_mouse_press)
self._add_connection('button_release_event', self._on_mouse_release)
self._add_connection('motion_notify_event', self._on_mouse_motion)
self._pressed_button = None # To store active button
self._axes = None # To store x and y axes concerned by interaction
self._event = None # To store reference event during interaction
@staticmethod
def _pan_update_limits(ax, axis_id, event, last_event):
"""Compute limits with applied pan."""
assert axis_id in (0, 1)
if axis_id == 0:
lim = ax.get_xlim()
scale = ax.get_xscale()
else:
lim = ax.get_ylim()
scale = ax.get_yscale()
pixel_to_data = ax.transData.inverted()
data = pixel_to_data.transform_point((event.x, event.y))
last_data = pixel_to_data.transform_point((last_event.x, last_event.y))
if scale == 'linear':
delta = data[axis_id] - last_data[axis_id]
new_lim = lim[0] - delta, lim[1] - delta
elif scale == 'log':
try:
delta = math.log10(data[axis_id]) - \
math.log10(last_data[axis_id])
new_lim = [pow(10., (math.log10(lim[0]) - delta)),
pow(10., (math.log10(lim[1]) - delta))]
except (ValueError, OverflowError):
new_lim = lim # Keep previous limits
else:
logging.warning('Pan not implemented for scale "%s"' % scale)
new_lim = lim
return new_lim
def _pan(self, event):
if event.name == 'button_press_event': # begin pan
self._event = event
elif event.name == 'button_release_event': # end pan
self._event = None
elif event.name == 'motion_notify_event': # pan
if self._event is None:
return
if event.x != self._event.x:
for ax in self._axes[0]:
xlim = self._pan_update_limits(ax, 0, event, self._event)
ax.set_xlim(xlim)
if event.y != self._event.y:
for ax in self._axes[1]:
ylim = self._pan_update_limits(ax, 1, event, self._event)
ax.set_ylim(ylim)
if event.x != self._event.x or event.y != self._event.y:
self._draw()
self._event = event
def _zoom_area(self, event):
if event.name == 'button_press_event': # begin drag
self._event = event
self._patch = _plt.Rectangle(
xy=(event.xdata, event.ydata), width=0, height=0,
fill=False, linewidth=1., linestyle='solid', color='black')
self._event.inaxes.add_patch(self._patch)
elif event.name == 'button_release_event': # end drag
self._patch.remove()
del self._patch
if (abs(event.x - self._event.x) < 3 or
abs(event.y - self._event.y) < 3):
return # No zoom when points are too close
x_axes, y_axes = self._axes
for ax in x_axes:
pixel_to_data = ax.transData.inverted()
begin_pt = pixel_to_data.transform_point((event.x, event.y))
end_pt = pixel_to_data.transform_point(
(self._event.x, self._event.y))
min_ = min(begin_pt[0], end_pt[0])
max_ = max(begin_pt[0], end_pt[0])
if not ax.xaxis_inverted():
ax.set_xlim(min_, max_)
else:
ax.set_xlim(max_, min_)
for ax in y_axes:
pixel_to_data = ax.transData.inverted()
begin_pt = pixel_to_data.transform_point((event.x, event.y))
end_pt = pixel_to_data.transform_point(
(self._event.x, self._event.y))
min_ = min(begin_pt[1], end_pt[1])
max_ = max(begin_pt[1], end_pt[1])
if not ax.yaxis_inverted():
ax.set_ylim(min_, max_)
else:
ax.set_ylim(max_, min_)
self._event = None
elif event.name == 'motion_notify_event': # drag
if self._event is None:
return
if event.inaxes != self._event.inaxes:
return # Ignore event outside plot
self._patch.set_width(event.xdata - self._event.xdata)
self._patch.set_height(event.ydata - self._event.ydata)
self._draw()
def _on_mouse_press(self, event):
if self._pressed_button is not None:
return # Discard event if a button is already pressed
if event.button in (1, 3): # Start
x_axes, y_axes = self._axes_to_update(event)
if x_axes or y_axes:
self._axes = x_axes, y_axes
self._pressed_button = event.button
if self._pressed_button == 1: # pan
self._pan(event)
elif self._pressed_button == 3: # zoom area
self._zoom_area(event)
def _on_mouse_release(self, event):
if self._pressed_button == event.button:
if self._pressed_button == 1: # pan
self._pan(event)
elif self._pressed_button == 3: # zoom area
self._zoom_area(event)
self._pressed_button = None
def _on_mouse_motion(self, event):
if self._pressed_button == 1: # pan
self._pan(event)
elif self._pressed_button == 3: # zoom area
self._zoom_area(event)
def figure_pz(*args, **kwargs):
"""matplotlib.pyplot.figure with pan and zoom interaction"""
fig = _plt.figure(*args, **kwargs)
fig.pan_zoom = PanAndZoom(fig)
return fig
if __name__ == "__main__":
import matplotlib.pyplot as plt
fig = figure_pz()
# Alternative:
# fig = plt.figure()
# pan_zoom = PanAndZoom(fig)
nrow, ncol = 2, 3
ax1 = fig.add_subplot(nrow, ncol, 1)
ax1.set_title('basic')
ax1.plot((1, 2, 3))
ax2 = fig.add_subplot(nrow, ncol, 2)
ax2.set_title('log + twinx')
ax2.set_yscale('log')
ax2.plot((1, 2, 1))
ax2bis = ax2.twinx()
ax2bis.plot((3, 2, 1), color='red')
ax3 = fig.add_subplot(nrow, ncol, 3)
ax3.set_title('inverted y axis')
ax3.plot((1, 2, 3))
lim = ax3.get_ylim()
ax3.set_ylim(lim[1], lim[0])
ax4 = fig.add_subplot(nrow, ncol, 4)
ax4.set_title('keep ratio')
ax4.axis('equal')
ax4.imshow(numpy.arange(100).reshape(10, 10))
ax5 = fig.add_subplot(nrow, ncol, 5)
ax5.set_xlabel('symlog scale + twiny')
ax5.set_xscale('symlog')
ax5.plot((1, 2, 3))
ax5bis = ax5.twiny()
ax5bis.plot((3, 2, 1), color='red')
# The following is taken from:
# http://matplotlib.org/examples/axes_grid/demo_curvelinear_grid.html
from mpl_toolkits.axisartist import Subplot
from mpl_toolkits.axisartist.grid_helper_curvelinear import \
GridHelperCurveLinear
def tr(x, y): # source (data) to target (rectilinear plot) coordinates
x, y = numpy.asarray(x), numpy.asarray(y)
return x + 0.2 * y, y - x
def inv_tr(x, y):
x, y = numpy.asarray(x), numpy.asarray(y)
return x - 0.2 * y, y + x
grid_helper = GridHelperCurveLinear((tr, inv_tr))
ax6 = Subplot(fig, nrow, ncol, 6, grid_helper=grid_helper)
fig.add_subplot(ax6)
ax6.set_title('non-ortho axes')
xx, yy = tr([3, 6], [5.0, 10.])
ax6.plot(xx, yy)
ax6.set_aspect(1.)
ax6.set_xlim(0, 10.)
ax6.set_ylim(0, 10.)
ax6.axis["t"] = ax6.new_floating_axis(0, 3.)
ax6.axis["t2"] = ax6.new_floating_axis(1, 7.)
ax6.grid(True)
plt.show()
@dukelec
Copy link

dukelec commented Oct 20, 2019

I'm add shift and ctrl key to limit x and y axis scale separately.
I don't know how to push my fork back to here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment