source: sasview/src/sas/qtgui/Plotting/Plotter.py @ c691d36

ESS_GUIESS_GUI_DocsESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_iss959ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since c691d36 was fef38e8, checked in by Piotr Rozyczko <rozyczko@…>, 7 years ago

Startup time improvements - hiding expensive imports and such

  • Property mode set to 100644
File size: 24.3 KB
Line 
1from PyQt4 import QtGui
2from PyQt4 import QtCore
3import functools
4import copy
5import matplotlib.pyplot as plt
6from matplotlib.font_manager import FontProperties
7from sas.qtgui.Plotting.PlotterData import Data1D
8from sas.qtgui.Plotting.PlotterBase import PlotterBase
9from sas.qtgui.Plotting.AddText import AddText
10from sas.qtgui.Plotting.SetGraphRange import SetGraphRange
11from sas.qtgui.Plotting.LinearFit import LinearFit
12from sas.qtgui.Plotting.PlotProperties import PlotProperties
13
14import sas.qtgui.Utilities.GuiUtils as GuiUtils
15import sas.qtgui.Plotting.PlotUtilities as PlotUtilities
16
17class PlotterWidget(PlotterBase):
18    """
19    1D Plot widget for use with a QDialog
20    """
21    def __init__(self, parent=None, manager=None, quickplot=False):
22        super(PlotterWidget, self).__init__(parent, manager=manager, quickplot=quickplot)
23
24        self.parent = parent
25
26        # Dictionary of {plot_id:Data1d}
27        self.plot_dict = {}
28
29        # Window for text add
30        self.addText = AddText(self)
31
32        # Log-ness of the axes
33        self.xLogLabel = "log10(x)"
34        self.yLogLabel = "log10(y)"
35
36        # Data container for the linear fit
37        self.fit_result = Data1D(x=[], y=[], dy=None)
38        self.fit_result.symbol = 13
39        self.fit_result.name = "Fit"
40
41        # Add a slot for receiving update signal from LinearFit
42        # NEW style signals
43        #self.updatePlot = QtCore.pyqtSignal(tuple)
44        # self.updatePlot.connect(self.onFitDisplay)
45        # OLD style signals
46        QtCore.QObject.connect(self, QtCore.SIGNAL('updatePlot'), self.onFitDisplay)
47
48    @property
49    def data(self):
50        return self._data
51
52    @data.setter
53    def data(self, value):
54        """ data setter """
55        self._data = value
56        self.xLabel = "%s(%s)"%(value._xaxis, value._xunit)
57        self.yLabel = "%s(%s)"%(value._yaxis, value._yunit)
58        self.title(title=value.name)
59
60    def plot(self, data=None, color=None, marker=None, hide_error=False):
61        """
62        Add a new plot of self._data to the chart.
63        """
64        # Data1D
65        if isinstance(data, Data1D):
66            self.data = data
67        assert(self._data)
68
69        is_fit = (self.data.id=="fit")
70
71        # Transform data if required.
72        # TODO: it properly!
73        #if data.xtransform is not None or data.ytransform is not None:
74        #    a, b, c, d = GuiUtils.xyTransform(self.data, self.data.xtransform, self.data.ytransform)
75
76        # Shortcuts
77        ax = self.ax
78        x = self._data.view.x
79        y = self._data.view.y
80
81        # Marker symbol. Passed marker is one of matplotlib.markers characters
82        # Alternatively, picked up from Data1D as an int index of PlotUtilities.SHAPES dict
83        if marker is None:
84            marker = self.data.symbol
85            # Try name first
86            try:
87                marker = PlotUtilities.SHAPES[marker]
88            except KeyError:
89                marker = PlotUtilities.SHAPES.values()[marker]
90
91        assert marker is not None
92        # Plot name
93        if self.data.title:
94            self.title(title=self.data.title)
95        else:
96            self.title(title=self.data.name)
97
98        # Error marker toggle
99        if hide_error is None:
100            hide_error = self.data.hide_error
101
102        # Plot color
103        if color is None:
104            color = self.data.custom_color
105
106        color = PlotUtilities.getValidColor(color)
107
108        markersize = self._data.markersize
109
110        # Draw non-standard markers
111        l_width = markersize * 0.4
112        if marker == '-' or marker == '--':
113            line = self.ax.plot(x, y, color=color, lw=l_width, marker='',
114                             linestyle=marker, label=self._title, zorder=10)[0]
115
116        elif marker == 'vline':
117            y_min = min(y)*9.0/10.0 if min(y) < 0 else 0.0
118            line = self.ax.vlines(x=x, ymin=y_min, ymax=y, color=color,
119                            linestyle='-', label=self._title, lw=l_width, zorder=1)
120
121        elif marker == 'step':
122            line = self.ax.step(x, y, color=color, marker='', linestyle='-',
123                                label=self._title, lw=l_width, zorder=1)[0]
124
125        else:
126            # plot data with/without errorbars
127            if hide_error:
128                line = ax.plot(x, y, marker=marker, color=color, markersize=markersize,
129                        linestyle='', label=self._title, picker=True)
130            else:
131                line = ax.errorbar(x, y,
132                            yerr=self._data.view.dy, xerr=None,
133                            capsize=2, linestyle='',
134                            barsabove=False,
135                            color=color,
136                            marker=marker,
137                            markersize=markersize,
138                            lolims=False, uplims=False,
139                            xlolims=False, xuplims=False,
140                            label=self._title,
141                            picker=True)
142
143        # Update the list of data sets (plots) in chart
144        self.plot_dict[self._data.id] = self.data
145
146        # Now add the legend with some customizations.
147        self.legend = ax.legend(loc='upper right', shadow=True)
148        if self.legend:
149            self.legend.set_picker(True)
150
151        # Current labels for axes
152        if self.y_label and not is_fit:
153            ax.set_ylabel(self.y_label)
154        if self.x_label and not is_fit:
155            ax.set_xlabel(self.x_label)
156
157        # Include scaling (log vs. linear)
158        ax.set_xscale(self.xscale)
159        ax.set_yscale(self.yscale)
160
161        # define the ranges
162        self.setRange = SetGraphRange(parent=self,
163            x_range=self.ax.get_xlim(), y_range=self.ax.get_ylim())
164
165        # refresh canvas
166        self.canvas.draw()
167
168    def createContextMenu(self):
169        """
170        Define common context menu and associated actions for the MPL widget
171        """
172        self.defaultContextMenu()
173
174        # Separate plots
175        self.addPlotsToContextMenu()
176
177        # Additional menu items
178        self.contextMenu.addSeparator()
179        self.actionAddText = self.contextMenu.addAction("Add Text")
180        self.actionRemoveText = self.contextMenu.addAction("Remove Text")
181        self.contextMenu.addSeparator()
182        self.actionChangeScale = self.contextMenu.addAction("Change Scale")
183        self.contextMenu.addSeparator()
184        self.actionSetGraphRange = self.contextMenu.addAction("Set Graph Range")
185        self.actionResetGraphRange =\
186            self.contextMenu.addAction("Reset Graph Range")
187        # Add the title change for dialogs
188        #if self.parent:
189        self.contextMenu.addSeparator()
190        self.actionWindowTitle = self.contextMenu.addAction("Window Title")
191
192        # Define the callbacks
193        self.actionAddText.triggered.connect(self.onAddText)
194        self.actionRemoveText.triggered.connect(self.onRemoveText)
195        self.actionChangeScale.triggered.connect(self.onScaleChange)
196        self.actionSetGraphRange.triggered.connect(self.onSetGraphRange)
197        self.actionResetGraphRange.triggered.connect(self.onResetGraphRange)
198        self.actionWindowTitle.triggered.connect(self.onWindowsTitle)
199
200    def addPlotsToContextMenu(self):
201        """
202        Adds operations on all plotted sets of data to the context menu
203        """
204        for id in self.plot_dict.keys():
205            plot = self.plot_dict[id]
206
207            name = plot.name if plot.name else plot.title
208            plot_menu = self.contextMenu.addMenu('&%s' % name)
209
210            self.actionDataInfo = plot_menu.addAction("&DataInfo")
211            self.actionDataInfo.triggered.connect(
212                                functools.partial(self.onDataInfo, plot))
213
214            self.actionSavePointsAsFile = plot_menu.addAction("&Save Points as a File")
215            self.actionSavePointsAsFile.triggered.connect(
216                                functools.partial(self.onSavePoints, plot))
217            plot_menu.addSeparator()
218
219            if plot.id != 'fit':
220                self.actionLinearFit = plot_menu.addAction('&Linear Fit')
221                self.actionLinearFit.triggered.connect(
222                                functools.partial(self.onLinearFit, id))
223                plot_menu.addSeparator()
224
225            self.actionRemovePlot = plot_menu.addAction("Remove")
226            self.actionRemovePlot.triggered.connect(
227                                functools.partial(self.onRemovePlot, id))
228
229            if not plot.is_data:
230                self.actionFreeze = plot_menu.addAction('&Freeze')
231                self.actionFreeze.triggered.connect(
232                                functools.partial(self.onFreeze, id))
233            plot_menu.addSeparator()
234
235            if plot.is_data:
236                self.actionHideError = plot_menu.addAction("Hide Error Bar")
237                if plot.dy is not None and plot.dy != []:
238                    if plot.hide_error:
239                        self.actionHideError.setText('Show Error Bar')
240                else:
241                    self.actionHideError.setEnabled(False)
242                self.actionHideError.triggered.connect(
243                                functools.partial(self.onToggleHideError, id))
244                plot_menu.addSeparator()
245
246            self.actionModifyPlot = plot_menu.addAction('&Modify Plot Property')
247            self.actionModifyPlot.triggered.connect(
248                                functools.partial(self.onModifyPlot, id))
249
250    def createContextMenuQuick(self):
251        """
252        Define context menu and associated actions for the quickplot MPL widget
253        """
254        # Default actions
255        self.defaultContextMenu()
256
257        # Additional actions
258        self.actionToggleGrid = self.contextMenu.addAction("Toggle Grid On/Off")
259        self.contextMenu.addSeparator()
260        self.actionChangeScale = self.contextMenu.addAction("Change Scale")
261
262        # Define the callbacks
263        self.actionToggleGrid.triggered.connect(self.onGridToggle)
264        self.actionChangeScale.triggered.connect(self.onScaleChange)
265
266    def onScaleChange(self):
267        """
268        Show a dialog allowing axes rescaling
269        """
270        if self.properties.exec_() == QtGui.QDialog.Accepted:
271            self.xLogLabel, self.yLogLabel = self.properties.getValues()
272            self.xyTransform(self.xLogLabel, self.yLogLabel)
273
274    def onAddText(self):
275        """
276        Show a dialog allowing adding custom text to the chart
277        """
278        if self.addText.exec_() == QtGui.QDialog.Accepted:
279            # Retrieve the new text, its font and color
280            extra_text = self.addText.text()
281            extra_font = self.addText.font()
282            extra_color = self.addText.color()
283
284            # Place the text on the screen at (0,0)
285            pos_x = self.x_click
286            pos_y = self.y_click
287
288            # Map QFont onto MPL font
289            mpl_font = FontProperties()
290            mpl_font.set_size(int(extra_font.pointSize()))
291            mpl_font.set_family(str(extra_font.family()))
292            mpl_font.set_weight(int(extra_font.weight()))
293            # MPL style names
294            styles = ['normal', 'italic', 'oblique']
295            # QFont::Style maps directly onto the above
296            try:
297                mpl_font.set_style(styles[extra_font.style()])
298            except:
299                pass
300
301            if len(extra_text) > 0:
302                new_text = self.ax.text(str(pos_x),
303                                        str(pos_y),
304                                        extra_text,
305                                        color=extra_color,
306                                        fontproperties=mpl_font)
307                # Update the list of annotations
308                self.textList.append(new_text)
309                self.canvas.draw_idle()
310
311    def onRemoveText(self):
312        """
313        Remove the most recently added text
314        """
315        num_text = len(self.textList)
316        if num_text < 1:
317            return
318        txt = self.textList[num_text - 1]
319        text_remove = txt.get_text()
320        txt.remove()
321        self.textList.remove(txt)
322
323        self.canvas.draw_idle()
324
325    def onSetGraphRange(self):
326        """
327        Show a dialog allowing setting the chart ranges
328        """
329        # min and max of data
330        if self.setRange.exec_() == QtGui.QDialog.Accepted:
331            x_range = self.setRange.xrange()
332            y_range = self.setRange.yrange()
333            if x_range is not None and y_range is not None:
334                self.ax.set_xlim(x_range)
335                self.ax.set_ylim(y_range)
336                self.canvas.draw_idle()
337
338    def onResetGraphRange(self):
339        """
340        Resets the chart X and Y ranges to their original values
341        """
342        x_range = (self.data.x.min(), self.data.x.max())
343        y_range = (self.data.y.min(), self.data.y.max())
344        if x_range is not None and y_range is not None:
345            self.ax.set_xlim(x_range)
346            self.ax.set_ylim(y_range)
347            self.canvas.draw_idle()
348
349    def onLinearFit(self, id):
350        """
351        Creates and displays a simple linear fit for the selected plot
352        """
353        selected_plot = self.plot_dict[id]
354
355        maxrange = (min(selected_plot.x), max(selected_plot.x))
356        fitrange = self.ax.get_xlim()
357
358        fit_dialog = LinearFit(parent=self,
359                    data=selected_plot,
360                    max_range=maxrange,
361                    fit_range=fitrange,
362                    xlabel=self.xLogLabel,
363                    ylabel=self.yLogLabel)
364        if fit_dialog.exec_() == QtGui.QDialog.Accepted:
365            return
366
367    def replacePlot(self, id, new_plot):
368        """
369        Remove plot 'id' and add 'new_plot' to the chart.
370        This effectlvely refreshes the chart with changes to one of its plots
371        """
372        self.removePlot(id)
373        self.plot(data=new_plot)
374
375    def onRemovePlot(self, id):
376        """
377        Responds to the plot delete action
378        """
379        self.removePlot(id)
380
381        if len(self.plot_dict) == 0:
382            # last plot: graph is empty must be the panel must be destroyed
383                self.parent.close()
384
385    def removePlot(self, id):
386        """
387        Deletes the selected plot from the chart
388        """
389        if id not in self.plot_dict.keys():
390            return
391
392        selected_plot = self.plot_dict[id]
393
394        plot_dict = copy.deepcopy(self.plot_dict)
395
396        # Labels might have been changed
397        xl = self.ax.xaxis.label.get_text()
398        yl = self.ax.yaxis.label.get_text()
399
400        self.plot_dict = {}
401
402        plt.cla()
403        self.ax.cla()
404
405        for ids in plot_dict:
406            if ids != id:
407                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)
408
409        # Reset the labels
410        self.ax.set_xlabel(xl)
411        self.ax.set_ylabel(yl)
412        self.canvas.draw()
413
414    def onFreeze(self, id):
415        """
416        Freezes the selected plot to a separate chart
417        """
418        plot = self.plot_dict[id]
419        self.manager.add_data(data_list=[plot])
420
421    def onModifyPlot(self, id):
422        """
423        Allows for MPL modifications to the selected plot
424        """
425        selected_plot = self.plot_dict[id]
426
427        # Old style color - single integer for enum color
428        # New style color - #hhhhhh
429        color = selected_plot.custom_color
430        # marker symbol and size
431        marker = selected_plot.symbol
432        marker_size = selected_plot.markersize
433        # plot name
434        legend = selected_plot.title
435
436        plotPropertiesWidget = PlotProperties(self,
437                                color=color,
438                                marker=marker,
439                                marker_size=marker_size,
440                                legend=legend)
441        if plotPropertiesWidget.exec_() == QtGui.QDialog.Accepted:
442            # Update Data1d
443            selected_plot.markersize = plotPropertiesWidget.markersize()
444            selected_plot.custom_color = plotPropertiesWidget.color()
445            selected_plot.symbol = plotPropertiesWidget.marker()
446            selected_plot.title = plotPropertiesWidget.legend()
447
448            # Redraw the plot
449            self.replacePlot(id, selected_plot)
450
451    def onToggleHideError(self, id):
452        """
453        Toggles hide error/show error menu item
454        """
455        selected_plot = self.plot_dict[id]
456        current = selected_plot.hide_error
457
458        # Flip the flag
459        selected_plot.hide_error = not current
460
461        plot_dict = copy.deepcopy(self.plot_dict)
462        self.plot_dict = {}
463
464        # Clean the canvas
465        plt.cla()
466        self.ax.cla()
467
468        # Recreate the plots but reverse the error flag for the current
469        for ids in plot_dict:
470            if ids == id:
471                self.plot(data=plot_dict[ids], hide_error=(not current))
472            else:
473                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)               
474
475    def xyTransform(self, xLabel="", yLabel=""):
476        """
477        Transforms x and y in View and set the scale
478        """
479        # Transform all the plots on the chart
480        for id in self.plot_dict.keys():
481            current_plot = self.plot_dict[id]
482            if current_plot.id == "fit":
483                self.removePlot(id)
484                continue
485
486            new_xlabel, new_ylabel, xscale, yscale =\
487                GuiUtils.xyTransform(current_plot, xLabel, yLabel)
488            self.xscale = xscale
489            self.yscale = yscale
490
491            # Plot the updated chart
492            self.removePlot(id)
493
494            # This assignment will wrap the label in Latex "$"
495            self.xLabel = new_xlabel
496            self.yLabel = new_ylabel
497            # Directly overwrite the data to avoid label reassignment
498            self._data = current_plot
499            self.plot()
500
501        pass # debug hook
502
503    def onFitDisplay(self, fit_data):
504        """
505        Add a linear fitting line to the chart
506        """
507        # Create new data structure with fitting result
508        tempx = fit_data[0]
509        tempy = fit_data[1]
510        self.fit_result.x = []
511        self.fit_result.y = []
512        self.fit_result.x = tempx
513        self.fit_result.y = tempy
514        self.fit_result.dx = None
515        self.fit_result.dy = None
516
517        #Remove another Fit, if exists
518        self.removePlot("fit")
519
520        self.fit_result.reset_view()
521        #self.offset_graph()
522
523        # Set plot properties
524        self.fit_result.id = 'fit'
525        self.fit_result.title = 'Fit'
526        self.fit_result.name = 'Fit'
527
528        # Plot the line
529        self.plot(data=self.fit_result, marker='-', hide_error=True)
530
531    def onMplMouseDown(self, event):
532        """
533        Left button down and ready to drag
534        """
535        # Check that the LEFT button was pressed
536        if event.button != 1:
537            return
538
539        self.leftdown = True
540        for text in self.textList:
541            if text.contains(event)[0]: # If user has clicked on text
542                self.selectedText = text
543                return
544        if event.inaxes is None:
545            return
546        try:
547            self.x_click = float(event.xdata)  # / size_x
548            self.y_click = float(event.ydata)  # / size_y
549        except:
550            self.position = None
551
552    def onMplMouseUp(self, event):
553        """
554        Set the data coordinates of the click
555        """
556        self.x_click = event.xdata
557        self.y_click = event.ydata
558
559        # Check that the LEFT button was released
560        if event.button == 1:
561            self.leftdown = False
562            self.selectedText = None
563
564        #release the legend
565        if self.gotLegend == 1:
566            self.gotLegend = 0
567
568    def onMplMouseMotion(self, event):
569        """
570        Check if the left button is press and the mouse in moving.
571        Compute delta for x and y coordinates and then perform the drag
572        """
573        if self.gotLegend == 1 and self.leftdown:
574            self.onLegendMotion(event)
575            return
576
577        #if self.leftdown and self.selectedText is not None:
578        if not self.leftdown or self.selectedText is None:
579            return
580        # User has clicked on text and is dragging
581        if event.inaxes is None:
582            # User has dragged outside of axes
583            self.selectedText = None
584        else:
585            # Only move text if mouse is within axes
586            self.selectedText.set_position((event.xdata, event.ydata))
587            self.canvas.draw_idle()
588        return
589
590    def onMplPick(self, event):
591        """
592        On pick legend
593        """
594        legend = self.legend
595        if event.artist != legend:
596            return
597        # Get the box of the legend.
598        bbox = self.legend.get_window_extent()
599        # Get mouse coordinates at time of pick.
600        self.mouse_x = event.mouseevent.x
601        self.mouse_y = event.mouseevent.y
602        # Get legend coordinates at time of pick.
603        self.legend_x = bbox.xmin
604        self.legend_y = bbox.ymin
605        # Indicate we picked up the legend.
606        self.gotLegend = 1
607
608        #self.legend.legendPatch.set_alpha(0.5)
609
610    def onLegendMotion(self, event):
611        """
612        On legend in motion
613        """
614        ax = event.inaxes
615        if ax == None:
616            return
617        # Event occurred inside a plotting area
618        lo_x, hi_x = ax.get_xlim()
619        lo_y, hi_y = ax.get_ylim()
620        # How much the mouse moved.
621        x = mouse_diff_x = self.mouse_x - event.x
622        y = mouse_diff_y = self.mouse_y - event.y
623        # Put back inside
624        if x < lo_x:
625            x = lo_x
626        if x > hi_x:
627            x = hi_x
628        if y < lo_y:
629            y = lo_y
630        if y > hi_y:
631            y = hi_y
632        # Move the legend from its previous location by that same amount
633        loc_in_canvas = self.legend_x - mouse_diff_x, \
634                        self.legend_y - mouse_diff_y
635        # Transform into legend coordinate system
636        trans_axes = self.legend.parent.transAxes.inverted()
637        loc_in_norm_axes = trans_axes.transform_point(loc_in_canvas)
638        self.legend_pos_loc = tuple(loc_in_norm_axes)
639        self.legend._loc = self.legend_pos_loc
640        # self.canvas.draw()
641        self.canvas.draw_idle()
642
643    def onMplWheel(self, event):
644        """
645        Process mouse wheel as zoom events
646        """
647        ax = event.inaxes
648        step = event.step
649
650        if ax != None:
651            # Event occurred inside a plotting area
652            lo, hi = ax.get_xlim()
653            lo, hi = PlotUtilities.rescale(lo, hi, step,
654                              pt=event.xdata, scale=ax.get_xscale())
655            if not self.xscale == 'log' or lo > 0:
656                self._scale_xlo = lo
657                self._scale_xhi = hi
658                ax.set_xlim((lo, hi))
659
660            lo, hi = ax.get_ylim()
661            lo, hi = PlotUtilities.rescale(lo, hi, step, pt=event.ydata,
662                              scale=ax.get_yscale())
663            if not self.yscale == 'log' or lo > 0:
664                self._scale_ylo = lo
665                self._scale_yhi = hi
666                ax.set_ylim((lo, hi))
667        else:
668            # Check if zoom happens in the axes
669            xdata, ydata = None, None
670            x, y = event.x, event.y
671
672            for ax in self.axes:
673                insidex, _ = ax.xaxis.contains(event)
674                if insidex:
675                    xdata, _ = ax.transAxes.inverted().transform_point((x, y))
676                insidey, _ = ax.yaxis.contains(event)
677                if insidey:
678                    _, ydata = ax.transAxes.inverted().transform_point((x, y))
679            if xdata is not None:
680                lo, hi = ax.get_xlim()
681                lo, hi = PlotUtilities.rescale(lo, hi, step,
682                                  bal=xdata, scale=ax.get_xscale())
683                if not self.xscale == 'log' or lo > 0:
684                    self._scale_xlo = lo
685                    self._scale_xhi = hi
686                    ax.set_xlim((lo, hi))
687            if ydata is not None:
688                lo, hi = ax.get_ylim()
689                lo, hi = PlotUtilities.rescale(lo, hi, step, bal=ydata,
690                                  scale=ax.get_yscale())
691                if not self.yscale == 'log' or lo > 0:
692                    self._scale_ylo = lo
693                    self._scale_yhi = hi
694                    ax.set_ylim((lo, hi))
695        self.canvas.draw_idle()
696
697
698class Plotter(QtGui.QDialog, PlotterWidget):
699    def __init__(self, parent=None, quickplot=False):
700
701        QtGui.QDialog.__init__(self)
702        PlotterWidget.__init__(self, parent=self, manager=parent, quickplot=quickplot)
703        icon = QtGui.QIcon()
704        icon.addPixmap(QtGui.QPixmap(":/res/ball.ico"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
705        self.setWindowIcon(icon)
706
707
Note: See TracBrowser for help on using the repository browser.