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

Last change on this file since c57ecca was f0bb711, checked in by celinedurniak <celine.durniak@…>, 7 years ago

Implemented comments from review for Data Operation Panel (ESS-GUI-SasView?-245)

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