source: sasview/src/sas/qtgui/Plotter.py @ 9f25bce

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

Towards more 1D plots responding to data change.
Minor bug fixes.

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