import pylab import numpy from PyQt4 import QtGui from PyQt4 import QtCore # TODO: Replace the qt4agg calls below with qt5 equivalent. # Requires some code modifications. # https://www.boxcontrol.net/embedding-matplotlib-plot-on-pyqt5-gui.html # from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt4agg import NavigationToolbar2QT as NavigationToolbar import matplotlib.pyplot as plt DEFAULT_CMAP = pylab.cm.jet from sas.sasgui.plottools.binder import BindArtist import sas.qtgui.GuiUtils as GuiUtils from sas.sasgui.guiframe.dataFitting import Data1D, Data2D from sas.qtgui.ScaleProperties import ScaleProperties from sas.qtgui.WindowTitle import WindowTitle import sas.qtgui.PlotHelper as PlotHelper import sas.qtgui.PlotUtilities as PlotUtilities class PlotterBase(QtGui.QWidget): def __init__(self, parent=None, manager=None, quickplot=False): super(PlotterBase, self).__init__(parent) # Required for the communicator self.manager = manager self.quickplot = quickplot # a figure instance to plot on self.figure = plt.figure() # Define canvas for the figure to be placed on self.canvas = FigureCanvas(self.figure) # ... and the toolbar with all the default MPL buttons self.toolbar = NavigationToolbar(self.canvas, self) # Simple window for data display self.txt_widget = QtGui.QTextEdit(None) # Set the layout and place the canvas widget in it. layout = QtGui.QVBoxLayout() layout.setMargin(0) layout.addWidget(self.canvas) # 1D plotter defaults self.current_plot = 111 self._data = [] # Original 1D/2D object self._xscale = 'log' self._yscale = 'log' self.qx_data = [] self.qy_data = [] self.color = 0 self.symbol = 0 self.grid_on = False self.scale = 'linear' self.x_label = "log10(x)" self.y_label = "log10(y)" # Mouse click related self._scale_xlo = None self._scale_xhi = None self._scale_ylo = None self._scale_yhi = None self.x_click = None self.y_click = None self.event_pos = None self.leftdown = False self.gotLegend = 0 # Annotations self.selectedText = None self.textList = [] # Pre-define the Scale properties dialog self.properties = ScaleProperties(self, init_scale_x=self.x_label, init_scale_y=self.y_label) # default color map self.cmap = DEFAULT_CMAP # Add the axes object -> subplot # TODO: self.ax will have to be tracked and exposed # to enable subplot specific operations self.ax = self.figure.add_subplot(self.current_plot) # Remove this, DAMMIT self.axes = [self.ax] # Set the background color to white self.canvas.figure.set_facecolor('#FFFFFF') # Canvas event handlers self.canvas.mpl_connect('button_release_event', self.onMplMouseUp) self.canvas.mpl_connect('button_press_event', self.onMplMouseDown) self.canvas.mpl_connect('motion_notify_event', self.onMplMouseMotion) self.canvas.mpl_connect('pick_event', self.onMplPick) self.canvas.mpl_connect('scroll_event', self.onMplWheel) self.contextMenu = QtGui.QMenu(self) if not quickplot: # Add the toolbar layout.addWidget(self.toolbar) # Notify PlotHelper about the new plot self.upatePlotHelper() self.setLayout(layout) @property def data(self): """ data getter """ return self._data @data.setter def data(self, data=None): """ Pure virtual data setter """ raise NotImplementedError("Data setter must be implemented in derived class.") def title(self, title=""): """ title setter """ self._title = title @property def xLabel(self, xlabel=""): """ x-label setter """ return self.x_label @xLabel.setter def xLabel(self, xlabel=""): """ x-label setter """ self.x_label = r'$%s$'% xlabel @property def yLabel(self, ylabel=""): """ y-label setter """ return self.y_label @yLabel.setter def yLabel(self, ylabel=""): """ y-label setter """ self.y_label = r'$%s$'% ylabel @property def yscale(self): """ Y-axis scale getter """ return self._yscale @yscale.setter def yscale(self, scale='linear'): """ Y-axis scale setter """ self.ax.set_yscale(scale, nonposy='clip') self._yscale = scale @property def xscale(self): """ X-axis scale getter """ return self._xscale @xscale.setter def xscale(self, scale='linear'): """ X-axis scale setter """ self.ax.set_xscale(scale) self._xscale = scale def upatePlotHelper(self): """ Notify the plot helper about the new plot """ # Notify the helper PlotHelper.addPlot(self) # Notify the listeners about a new graph self.manager.communicator.activeGraphsSignal.emit(PlotHelper.currentPlots()) def defaultContextMenu(self): """ Content of the dialog-universal context menu: Save, Print and Copy """ # Actions self.contextMenu.clear() self.actionSaveImage = self.contextMenu.addAction("Save Image") self.actionPrintImage = self.contextMenu.addAction("Print Image") self.actionCopyToClipboard = self.contextMenu.addAction("Copy to Clipboard") self.contextMenu.addSeparator() # Define the callbacks self.actionSaveImage.triggered.connect(self.onImageSave) self.actionPrintImage.triggered.connect(self.onImagePrint) self.actionCopyToClipboard.triggered.connect(self.onClipboardCopy) def createContextMenu(self): """ Define common context menu and associated actions for the MPL widget """ raise NotImplementedError("Context menu method must be implemented in derived class.") def createContextMenuQuick(self): """ Define context menu and associated actions for the quickplot MPL widget """ raise NotImplementedError("Context menu method must be implemented in derived class.") def contextMenuEvent(self, event): """ Display the context menu """ if not self.quickplot: self.createContextMenu() else: self.createContextMenuQuick() event_pos = event.pos() self.contextMenu.exec_(self.canvas.mapToGlobal(event_pos)) def onMplMouseDown(self, event): """ Left button down and ready to drag """ # Check that the LEFT button was pressed if event.button == 1: self.leftdown = True ax = event.inaxes for text in self.textList: if text.contains(event)[0]: # If user has clicked on text self.selectedText = text return if ax != None: self.xInit, self.yInit = event.xdata, event.ydata try: self.x_click = float(event.xdata) # / size_x self.y_click = float(event.ydata) # / size_y except: self.position = None def onMplMouseUp(self, event): """ Set the data coordinates of the click """ self.x_click = event.xdata self.y_click = event.ydata # Check that the LEFT button was released if event.button == 1: self.leftdown = False #self.leftup = True self.selectedText = None #release the legend if self.gotLegend == 1: self.gotLegend = 0 def onMplMouseMotion(self, event): """ Check if the left button is press and the mouse in moving. Compute delta for x and y coordinates and then perform the drag """ if self.gotLegend == 1 and self.leftdown: self.onLegendMotion(event) return if self.leftdown and self.selectedText is not None: # User has clicked on text and is dragging ax = event.inaxes if ax != None: # Only move text if mouse is within axes self.selectedText.set_position((event.xdata, event.ydata)) self.canvas.draw_idle() else: # User has dragged outside of axes self.selectedText = None return def onMplPick(self, event): """ On pick legend """ legend = self.legend if event.artist == legend: # Get the box of the legend. bbox = self.legend.get_window_extent() # Get mouse coordinates at time of pick. self.mouse_x = event.mouseevent.x self.mouse_y = event.mouseevent.y # Get legend coordinates at time of pick. self.legend_x = bbox.xmin self.legend_y = bbox.ymin # Indicate we picked up the legend. self.gotLegend = 1 #self.legend.legendPatch.set_alpha(0.5) def onLegendMotion(self, event): """ On legend in motion """ ax = event.inaxes if ax == None: return # Event occurred inside a plotting area lo_x, hi_x = ax.get_xlim() lo_y, hi_y = ax.get_ylim() # How much the mouse moved. x = mouse_diff_x = self.mouse_x - event.x y = mouse_diff_y = self.mouse_y - event.y # Put back inside if x < lo_x: x = lo_x if x > hi_x: x = hi_x if y < lo_y: y = lo_y if y > hi_y: y = hi_y # Move the legend from its previous location by that same amount loc_in_canvas = self.legend_x - mouse_diff_x, \ self.legend_y - mouse_diff_y # Transform into legend coordinate system trans_axes = self.legend.parent.transAxes.inverted() loc_in_norm_axes = trans_axes.transform_point(loc_in_canvas) self.legend_pos_loc = tuple(loc_in_norm_axes) self.legend._loc = self.legend_pos_loc # self.canvas.draw() self.canvas.draw_idle() def onMplWheel(self, event): """ Process mouse wheel as zoom events """ ax = event.inaxes step = event.step if ax != None: # Event occurred inside a plotting area lo, hi = ax.get_xlim() lo, hi = PlotUtilities.rescale(lo, hi, step, pt=event.xdata, scale=ax.get_xscale()) if not self.xscale == 'log' or lo > 0: self._scale_xlo = lo self._scale_xhi = hi ax.set_xlim((lo, hi)) lo, hi = ax.get_ylim() lo, hi = PlotUtilities.rescale(lo, hi, step, pt=event.ydata, scale=ax.get_yscale()) if not self.yscale == 'log' or lo > 0: self._scale_ylo = lo self._scale_yhi = hi ax.set_ylim((lo, hi)) else: # Check if zoom happens in the axes xdata, ydata = None, None x, y = event.x, event.y for ax in self.axes: insidex, _ = ax.xaxis.contains(event) if insidex: xdata, _ = ax.transAxes.inverted().transform_point((x, y)) insidey, _ = ax.yaxis.contains(event) if insidey: _, ydata = ax.transAxes.inverted().transform_point((x, y)) if xdata is not None: lo, hi = ax.get_xlim() lo, hi = PlotUtilities.rescale(lo, hi, step, bal=xdata, scale=ax.get_xscale()) if not self.xscale == 'log' or lo > 0: self._scale_xlo = lo self._scale_xhi = hi ax.set_xlim((lo, hi)) if ydata is not None: lo, hi = ax.get_ylim() lo, hi = PlotUtilities.rescale(lo, hi, step, bal=ydata, scale=ax.get_yscale()) if not self.yscale == 'log' or lo > 0: self._scale_ylo = lo self._scale_yhi = hi ax.set_ylim((lo, hi)) self.canvas.draw_idle() def clean(self): """ Redraw the graph """ self.figure.delaxes(self.ax) self.ax = self.figure.add_subplot(self.current_plot) def plot(self, marker=None, linestyle=None): """ PURE VIRTUAL Plot the content of self._data """ raise NotImplementedError("Plot method must be implemented in derived class.") def closeEvent(self, event): """ Overwrite the close event adding helper notification """ # Please remove me from your database. PlotHelper.deletePlot(PlotHelper.idOfPlot(self)) # Notify the listeners self.manager.communicator.activeGraphsSignal.emit(PlotHelper.currentPlots()) event.accept() def onImageSave(self): """ Use the internal MPL method for saving to file """ self.toolbar.save_figure() def onImagePrint(self): """ Display printer dialog and print the MPL widget area """ # Define the printer printer = QtGui.QPrinter() # Display the print dialog dialog = QtGui.QPrintDialog(printer) dialog.setModal(True) dialog.setWindowTitle("Print") if dialog.exec_() != QtGui.QDialog.Accepted: return painter = QtGui.QPainter(printer) # Grab the widget screenshot pmap = QtGui.QPixmap.grabWidget(self) # Create a label with pixmap drawn printLabel = QtGui.QLabel() printLabel.setPixmap(pmap) # Print the label printLabel.render(painter) painter.end() def onClipboardCopy(self): """ Copy MPL widget area to buffer """ bmp = QtGui.QApplication.clipboard() pixmap = QtGui.QPixmap.grabWidget(self.canvas) bmp.setPixmap(pixmap) def onGridToggle(self): """ Add/remove grid lines from MPL plot """ self.grid_on = (not self.grid_on) self.ax.grid(self.grid_on) self.canvas.draw_idle() def onWindowsTitle(self): """ Show a dialog allowing chart title customisation """ current_title = self.windowTitle() titleWidget = WindowTitle(self, new_title=current_title) result = titleWidget.exec_() if result != QtGui.QDialog.Accepted: return title = titleWidget.title() self.setWindowTitle(title) # Notify the listeners about a new graph title self.manager.communicator.activeGraphName.emit((current_title, title)) def offset_graph(self): """ Zoom and offset the graph to the last known settings """ for ax in self.axes: if self._scale_xhi is not None and self._scale_xlo is not None: ax.set_xlim(self._scale_xlo, self._scale_xhi) if self._scale_yhi is not None and self._scale_ylo is not None: ax.set_ylim(self._scale_ylo, self._scale_yhi) def onDataInfo(self, plot_data): """ Displays data info text window for the selected plot """ if isinstance(plot_data, Data1D): text_to_show = GuiUtils.retrieveData1d(plot_data) else: text_to_show = GuiUtils.retrieveData2d(plot_data) # Hardcoded sizes to enable full width rendering with default font self.txt_widget.resize(420,600) self.txt_widget.setReadOnly(True) self.txt_widget.setWindowFlags(QtCore.Qt.Window) self.txt_widget.setWindowIcon(QtGui.QIcon(":/res/ball.ico")) self.txt_widget.setWindowTitle("Data Info: %s" % plot_data.filename) self.txt_widget.insertPlainText(text_to_show) self.txt_widget.show() # Move the slider all the way up, if present vertical_scroll_bar = self.txt_widget.verticalScrollBar() vertical_scroll_bar.triggerAction(QtGui.QScrollBar.SliderToMinimum) def onSavePoints(self, plot_data): """ Saves plot data to a file """ if isinstance(plot_data, Data1D): GuiUtils.saveData1D(plot_data) else: GuiUtils.saveData2D(plot_data)