source: sasview/src/sas/qtgui/Plotter.py @ 1228007

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

More minor fixes to plotting

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