source: sasview/src/sas/qtgui/Plotter.py @ 3bdbfcc

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 3bdbfcc was 3bdbfcc, checked in by Piotr Rozyczko <rozyczko@…>, 7 years ago

Reimplementation of the slicer functionality

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