# -*- coding: utf-8 -*-
# This code is part of Qiskit.
#
# (C) Copyright IBM 2017, 2021.
#
# This code is licensed under the Apache License, Version 2.0. You may
# obtain a copy of this license in the LICENSE.txt file in the root directory
# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
#
# Any modifications or derivative works of this code must retain this
# copyright notice, and modified files need to carry a notice indicating
# that they have been altered from the originals.
# /*##########################################################################
#
# 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:
.. code-block:: python
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:
.. code-block:: python
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 warnings
import weakref
import matplotlib.pyplot as _plt
import numpy
from PySide2.QtCore import Qt
from PySide2.QtGui import QIcon
from PySide2.QtWidgets import QAction, QLabel
from ... import Dict
__all__ = ['figure_pz', 'MplInteraction', 'PanAndZoom']
[docs]
class MplInteraction(object):
"""Base class for class providing interaction to a matplotlib Figure."""
def __init__(self, figure):
"""
Args:
figure (figure): The matplotlib figure to attach the behavior to.
"""
self._fig_ref = weakref.ref(figure)
self._cids = []
def __del__(self):
"""Disconnect."""
self.disconnect()
def _add_connection(self, event_name, callback):
"""Called to add a connection to an event of the figure.
Args:
event_name (str): The matplotlib event name to connect to.
callback (callback): The callback to register to this event.
"""
cid = self.figure.canvas.mpl_connect(event_name, callback)
self._cids.append(cid)
[docs]
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.
Args:
event (MouseEvent): Matplotlib event to consider
Returns:
tuple: Axes for which to update xlimits and ylimits.
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):
"""Convenient method to redraw the figure."""
self.figure.canvas.draw()
[docs]
class ZoomOnWheel(MplInteraction):
"""Class providing zoom on wheel interaction to a matplotlib Figure.
This class extends the `MplInteraction` class.
Supports subplots, twin Axes and log scales.
"""
def __init__(self, figure=None, scale_factor=1.1):
"""
Args:
figure (figure): The matplotlib figure to attach the behavior to.
scale_factor (float): 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.
Args:
begin (float): The begin bound of the range
end (float): The end bound of the range
center (float): The center of the zoom (i.e., invariant point)
scale_factor (float): The scale factor to apply
scale (str): The scale of the axis
Returns:
tuple: 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):
"""Mouse wheel 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()
[docs]
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.
This class extends the `ZoomOnWheel` class.
"""
def __init__(self, figure=None, scale_factor=1.1):
"""
Args:
figure (figure): The matplotlib figure to attach the behavior to.
scale_factor (float): 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
self.options = Dict(dict(report_point_position=True,))
self.logger = None
self._statusbar_label = None
# self._get_images_path()
# self._add_toolbar_tools()
self._style_figure()
self._ix_iy_old = (0, 0)
def _get_images_path(self):
"""Get the path to images.
Returns:
str: path
Raises:
Exception: path error
"""
# to be removed
try: # Get tool image path
from pathlib import Path
from ... import _gui
imgs_path = Path(_gui.__file__).parent / '_imgs'
if imgs_path.is_dir() == False:
print(f'Bad File path for images! {imgs_path}')
imgs_path = None
except Exception as e:
print('ERROR: ', e)
imgs_path = None
self.imgs_path = imgs_path
return imgs_path
def _add_toolbar_tools(self):
"""Add tools."""
# TODO: Outdated - to be removed
from matplotlib.backend_tools import ToolToggleBase # ToolBase
class ToolPointPosition(ToolToggleBase):
"""Tools."""
default_keymap = 'Ctrl+p'
description = 'Click to get point coordinate printed'
default_toggled = False
image = None # str(imgs_path)
def __init__(self, *args, parent=None, **kwargs):
super().__init__(*args, **kwargs)
if parent is None:
raise ('Pass a parent')
self.parent = parent
def enable(self, *args):
self.parent.options.report_point_position = True
def disable(self, *args):
self.parent.options.report_point_position = False
fig = self.figure
imgs_path = self.imgs_path
# pylint: disable=attribute-defined-outside-init
toolbar = self.toolbar = fig.canvas.manager.toolbar
# Get tool manager
# TODO: Remove use of tool manager just use PySide2 bare as below
# ToolbarQt --- https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/backends/backend_qt5.py
tm = fig.canvas.manager.toolmanager
self.tm = tm
# Tool: Print point location
ToolPointPosition.image = str(imgs_path / 'click.png')
with warnings.catch_warnings():
warnings.simplefilter("ignore")
tm.add_tool("Point_position", ToolPointPosition, parent=self)
fig.canvas.manager.toolbar.add_tool(tm.get_tool("Point_position"),
"toolgroup")
# Tool: Copy to Clipboard
from matplotlib.backend_tools import ToolCopyToClipboard
ToolCopyToClipboard.image = str(imgs_path / 'copy.png')
with warnings.catch_warnings():
warnings.simplefilter("ignore")
# OVvrwties Ctrl+C and issues warning
tm.add_tool("Copy_to_clipboard", ToolCopyToClipboard)
fig.canvas.manager.toolbar.add_tool(tm.get_tool("Copy_to_clipboard"),
"toolgroup")
if 1: # add QT Pieces
toolbar.action_ascale = QAction(
QIcon(str(imgs_path / 'auto_zoom.png')), 'Auto scale', toolbar)
toolbar.action_ascale.setShortcut('A')
toolbar.action_ascale.setShortcutContext(Qt.WindowShortcut)
toolbar.action_ascale.setStatusTip('Autoscale')
toolbar.action_ascale.triggered.connect(self.auto_scale)
toolbar.addAction(toolbar.action_ascale)
# Status Bar: Second label to report
figManager = fig.canvas.manager # plt.get_current_fig_manager()
status_bar = figManager.window.statusBar()
self._status_label_2 = QLabel(status_bar)
self._status_label_2.setText('')
status_bar.addWidget(self._status_label_2)
#from matplotlib.backends.backend_qt5 import StatusbarQt
#st = StatusbarQt(figManager.window, figManager.toolmanager)
# figManager.statusbar.set_message('')
[docs]
def auto_scale(self):
"""Auto scaler."""
for ax in self.figure.axes:
ax.autoscale()
# self.figure.canvas.flush_events()
self.figure.canvas.draw()
def _style_figure(self):
"""Style figure."""
#self.figure.dpi = 150
pass
@staticmethod
def _pan_update_limits(ax, axis_id, event, last_event):
"""Compute limits with applied pan.
Args:
axis_id (int): ID of the axis
event (event): The event
last_event (event): The previous event
Returns:
double: New limit
Raises:
ValueError: Value error
OverflowError: Overflow error
"""
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):
"""Pan.
Args:
event (event): The 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):
"""Zoom.
Args:
event (event): The event
"""
if event.name == 'button_press_event': # begin drag
self._event = event
# pylint: disable=attribute-defined-outside-init
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):
"""Mouse press event.
Args:
event (event): The 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)
if self.options.report_point_position: # check if we want to report point
self._report_point_position(event)
elif self._pressed_button == 3: # zoom area
self._zoom_area(event)
def _on_mouse_release(self, event):
"""Mouse release event.
Args:
event (event): The 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):
"""Mouse motion event.
Args:
event (event): The event
"""
if self._pressed_button == 1: # pan
self._pan(event)
elif self._pressed_button == 3: # zoom area
self._zoom_area(event)
def _report_point_position(self, event):
"""Report point position.
Args:
event (event): the event
"""
ix, iy = event.xdata, event.ydata
if hasattr(self, '_ix_iy_old'):
ix_old, iy_old = self._ix_iy_old
else:
ix_old, iy_old = (ix, iy)
self._ix_iy_old = ix, iy
_text = f'(x,y) = ({ix:.4f}, {iy:.4f}) Δ last point ({ix-ix_old:.4f}, {iy-iy_old:.4f})'
if self.logger:
self.logger.info(_text)
if self._statusbar_label:
self._statusbar_label.setText(_text)
# print(_text)
def figure_pz(*args, **kwargs):
"""matplotlib.pyplot.figure with pan and zoom interaction."""
#import warnings
# warnings.filterwarnings(action='ignore')
with warnings.catch_warnings():
warnings.simplefilter("ignore")
fig = _plt.figure(*args, **kwargs)
fig.pan_zoom = PanAndZoom(fig)
# warnings.resetwarnings()
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()
# """