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

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

Initial commit of Celine's Invariant Perspective work SASVIEW-52

  • Property mode set to 100644
File size: 24.2 KB
Line 
1from PyQt5 import QtCore
2from PyQt5 import QtGui
3from PyQt5 import QtWidgets
4
5import functools
6import copy
7import matplotlib.pyplot as plt
8from matplotlib.font_manager import FontProperties
9from sas.qtgui.Plotting.PlotterData import Data1D
10from sas.qtgui.Plotting.PlotterBase import PlotterBase
11from sas.qtgui.Plotting.AddText import AddText
12from sas.qtgui.Plotting.SetGraphRange import SetGraphRange
13from sas.qtgui.Plotting.LinearFit import LinearFit
14from sas.qtgui.Plotting.PlotProperties import PlotProperties
15
16import sas.qtgui.Utilities.GuiUtils as GuiUtils
17import sas.qtgui.Plotting.PlotUtilities as PlotUtilities
18
19class PlotterWidget(PlotterBase):
20    """
21    1D Plot widget for use with a QDialog
22    """
23    def __init__(self, parent=None, manager=None, quickplot=False):
24        super(PlotterWidget, self).__init__(parent, manager=manager, quickplot=quickplot)
25
26        self.parent = parent
27
28        # Dictionary of {plot_id:Data1d}
29        self.plot_dict = {}
30
31        # Window for text add
32        self.addText = AddText(self)
33
34        # Log-ness of the axes
35        self.xLogLabel = "log10(x)"
36        self.yLogLabel = "log10(y)"
37
38        # Data container for the linear fit
39        self.fit_result = Data1D(x=[], y=[], dy=None)
40        self.fit_result.symbol = 13
41        self.fit_result.name = "Fit"
42
43    @property
44    def data(self):
45        return self._data
46
47    @data.setter
48    def data(self, value):
49        """ data setter """
50        self._data = value
51        self.xLabel = "%s(%s)"%(value._xaxis, value._xunit)
52        self.yLabel = "%s(%s)"%(value._yaxis, value._yunit)
53        if value.isSesans:
54            self.xscale = 'linear'
55            self.yscale = 'linear'
56        self.title(title=value.name)
57
58    def plot(self, data=None, color=None, marker=None, hide_error=False):
59        """
60        Add a new plot of self._data to the chart.
61        """
62        # Data1D
63        if isinstance(data, Data1D):
64            self.data = data
65        assert(self._data)
66
67        is_fit = (self.data.id=="fit")
68
69        # Transform data if required.
70        # TODO: it properly!
71        #if data.xtransform is not None or data.ytransform is not None:
72        #    a, b, c, d = GuiUtils.xyTransform(self.data, self.data.xtransform, self.data.ytransform)
73
74        # Shortcuts
75        ax = self.ax
76        x = self._data.view.x
77        y = self._data.view.y
78
79        # Marker symbol. Passed marker is one of matplotlib.markers characters
80        # Alternatively, picked up from Data1D as an int index of PlotUtilities.SHAPES dict
81        if marker is None:
82            marker = self.data.symbol
83            # Try name first
84            try:
85                marker = PlotUtilities.SHAPES[marker]
86            except KeyError:
87                marker = list(PlotUtilities.SHAPES.values())[marker]
88
89        assert marker is not None
90        # Plot name
91        if self.data.title:
92            self.title(title=self.data.title)
93        else:
94            self.title(title=self.data.name)
95
96        # Error marker toggle
97        if hide_error is None:
98            hide_error = self.data.hide_error
99
100        # Plot color
101        if color is None:
102            color = self.data.custom_color
103
104        color = PlotUtilities.getValidColor(color)
105
106        markersize = self._data.markersize
107
108        # Draw non-standard markers
109        l_width = markersize * 0.4
110        if marker == '-' or marker == '--':
111            line = self.ax.plot(x, y, color=color, lw=l_width, marker='',
112                             linestyle=marker, label=self._title, zorder=10)[0]
113
114        elif marker == 'vline':
115            y_min = min(y)*9.0/10.0 if min(y) < 0 else 0.0
116            line = self.ax.vlines(x=x, ymin=y_min, ymax=y, color=color,
117                            linestyle='-', label=self._title, lw=l_width, zorder=1)
118
119        elif marker == 'step':
120            line = self.ax.step(x, y, color=color, marker='', linestyle='-',
121                                label=self._title, lw=l_width, zorder=1)[0]
122
123        else:
124            # plot data with/without errorbars
125            if hide_error:
126                line = ax.plot(x, y, marker=marker, color=color, markersize=markersize,
127                        linestyle='', label=self._title, picker=True)
128            else:
129                line = ax.errorbar(x, y,
130                            yerr=self._data.view.dy, xerr=None,
131                            capsize=2, linestyle='',
132                            barsabove=False,
133                            color=color,
134                            marker=marker,
135                            markersize=markersize,
136                            lolims=False, uplims=False,
137                            xlolims=False, xuplims=False,
138                            label=self._title,
139                            picker=True)
140
141        # Update the list of data sets (plots) in chart
142        self.plot_dict[self._data.id] = self.data
143
144        # Now add the legend with some customizations.
145
146        self.legend = ax.legend(loc='upper right', shadow=True)
147        if self.legend:
148            self.legend.set_picker(True)
149
150        # Current labels for axes
151        if self.y_label and not is_fit:
152            ax.set_ylabel(self.y_label)
153        if self.x_label and not is_fit:
154            ax.set_xlabel(self.x_label)
155
156        # Include scaling (log vs. linear)
157        ax.set_xscale(self.xscale)
158        ax.set_yscale(self.yscale)
159
160        # define the ranges
161        self.setRange = SetGraphRange(parent=self,
162            x_range=self.ax.get_xlim(), y_range=self.ax.get_ylim())
163
164        # refresh canvas
165        self.canvas.draw_idle()
166
167    def createContextMenu(self):
168        """
169        Define common context menu and associated actions for the MPL widget
170        """
171        self.defaultContextMenu()
172
173        # Separate plots
174        self.addPlotsToContextMenu()
175
176        # Additional menu items
177        self.contextMenu.addSeparator()
178        self.actionAddText = self.contextMenu.addAction("Add Text")
179        self.actionRemoveText = self.contextMenu.addAction("Remove Text")
180        self.contextMenu.addSeparator()
181        self.actionChangeScale = self.contextMenu.addAction("Change Scale")
182        self.contextMenu.addSeparator()
183        self.actionSetGraphRange = self.contextMenu.addAction("Set Graph Range")
184        self.actionResetGraphRange =\
185            self.contextMenu.addAction("Reset Graph Range")
186        # Add the title change for dialogs
187        #if self.parent:
188        self.contextMenu.addSeparator()
189        self.actionWindowTitle = self.contextMenu.addAction("Window Title")
190
191        # Define the callbacks
192        self.actionAddText.triggered.connect(self.onAddText)
193        self.actionRemoveText.triggered.connect(self.onRemoveText)
194        self.actionChangeScale.triggered.connect(self.onScaleChange)
195        self.actionSetGraphRange.triggered.connect(self.onSetGraphRange)
196        self.actionResetGraphRange.triggered.connect(self.onResetGraphRange)
197        self.actionWindowTitle.triggered.connect(self.onWindowsTitle)
198
199    def addPlotsToContextMenu(self):
200        """
201        Adds operations on all plotted sets of data to the context menu
202        """
203        for id in list(self.plot_dict.keys()):
204            plot = self.plot_dict[id]
205
206            name = plot.name if plot.name else plot.title
207            plot_menu = self.contextMenu.addMenu('&%s' % name)
208
209            self.actionDataInfo = plot_menu.addAction("&DataInfo")
210            self.actionDataInfo.triggered.connect(
211                                functools.partial(self.onDataInfo, plot))
212
213            self.actionSavePointsAsFile = plot_menu.addAction("&Save Points as a File")
214            self.actionSavePointsAsFile.triggered.connect(
215                                functools.partial(self.onSavePoints, plot))
216            plot_menu.addSeparator()
217
218            if plot.id != 'fit':
219                self.actionLinearFit = plot_menu.addAction('&Linear Fit')
220                self.actionLinearFit.triggered.connect(
221                                functools.partial(self.onLinearFit, id))
222                plot_menu.addSeparator()
223
224            self.actionRemovePlot = plot_menu.addAction("Remove")
225            self.actionRemovePlot.triggered.connect(
226                                functools.partial(self.onRemovePlot, id))
227
228            if not plot.is_data:
229                self.actionFreeze = plot_menu.addAction('&Freeze')
230                self.actionFreeze.triggered.connect(
231                                functools.partial(self.onFreeze, id))
232            plot_menu.addSeparator()
233
234            if plot.is_data:
235                self.actionHideError = plot_menu.addAction("Hide Error Bar")
236                if plot.dy is not None and plot.dy != []:
237                    if plot.hide_error:
238                        self.actionHideError.setText('Show Error Bar')
239                else:
240                    self.actionHideError.setEnabled(False)
241                self.actionHideError.triggered.connect(
242                                functools.partial(self.onToggleHideError, id))
243                plot_menu.addSeparator()
244
245            self.actionModifyPlot = plot_menu.addAction('&Modify Plot Property')
246            self.actionModifyPlot.triggered.connect(
247                                functools.partial(self.onModifyPlot, id))
248
249    def createContextMenuQuick(self):
250        """
251        Define context menu and associated actions for the quickplot MPL widget
252        """
253        # Default actions
254        self.defaultContextMenu()
255
256        # Additional actions
257        self.actionToggleGrid = self.contextMenu.addAction("Toggle Grid On/Off")
258        self.contextMenu.addSeparator()
259        self.actionChangeScale = self.contextMenu.addAction("Change Scale")
260
261        # Define the callbacks
262        self.actionToggleGrid.triggered.connect(self.onGridToggle)
263        self.actionChangeScale.triggered.connect(self.onScaleChange)
264
265    def onScaleChange(self):
266        """
267        Show a dialog allowing axes rescaling
268        """
269        if self.properties.exec_() == QtWidgets.QDialog.Accepted:
270            self.xLogLabel, self.yLogLabel = self.properties.getValues()
271            self.xyTransform(self.xLogLabel, self.yLogLabel)
272
273    def onAddText(self):
274        """
275        Show a dialog allowing adding custom text to the chart
276        """
277        if self.addText.exec_() == QtWidgets.QDialog.Accepted:
278            # Retrieve the new text, its font and color
279            extra_text = self.addText.text()
280            extra_font = self.addText.font()
281            extra_color = self.addText.color()
282
283            # Place the text on the screen at (0,0)
284            pos_x = self.x_click
285            pos_y = self.y_click
286
287            # Map QFont onto MPL font
288            mpl_font = FontProperties()
289            mpl_font.set_size(int(extra_font.pointSize()))
290            mpl_font.set_family(str(extra_font.family()))
291            mpl_font.set_weight(int(extra_font.weight()))
292            # MPL style names
293            styles = ['normal', 'italic', 'oblique']
294            # QFont::Style maps directly onto the above
295            try:
296                mpl_font.set_style(styles[extra_font.style()])
297            except:
298                pass
299
300            if len(extra_text) > 0:
301                new_text = self.ax.text(str(pos_x),
302                                        str(pos_y),
303                                        extra_text,
304                                        color=extra_color,
305                                        fontproperties=mpl_font)
306                # Update the list of annotations
307                self.textList.append(new_text)
308                self.canvas.draw_idle()
309                pass
310
311    def onRemoveText(self):
312        """
313        Remove the most recently added text
314        """
315        num_text = len(self.textList)
316        if num_text < 1:
317            return
318        txt = self.textList[num_text - 1]
319        text_remove = txt.get_text()
320        txt.remove()
321        self.textList.remove(txt)
322
323        self.canvas.draw_idle()
324
325    def onSetGraphRange(self):
326        """
327        Show a dialog allowing setting the chart ranges
328        """
329        # min and max of data
330        if self.setRange.exec_() == QtWidgets.QDialog.Accepted:
331            x_range = self.setRange.xrange()
332            y_range = self.setRange.yrange()
333            if x_range is not None and y_range is not None:
334                self.ax.set_xlim(x_range)
335                self.ax.set_ylim(y_range)
336                self.canvas.draw_idle()
337
338    def onResetGraphRange(self):
339        """
340        Resets the chart X and Y ranges to their original values
341        """
342        x_range = (self.data.x.min(), self.data.x.max())
343        y_range = (self.data.y.min(), self.data.y.max())
344        if x_range is not None and y_range is not None:
345            self.ax.set_xlim(x_range)
346            self.ax.set_ylim(y_range)
347            self.canvas.draw_idle()
348
349    def onLinearFit(self, id):
350        """
351        Creates and displays a simple linear fit for the selected plot
352        """
353        selected_plot = self.plot_dict[id]
354
355        maxrange = (min(selected_plot.x), max(selected_plot.x))
356        fitrange = self.ax.get_xlim()
357
358        fit_dialog = LinearFit(parent=self,
359                    data=selected_plot,
360                    max_range=maxrange,
361                    fit_range=fitrange,
362                    xlabel=self.xLogLabel,
363                    ylabel=self.yLogLabel)
364        fit_dialog.updatePlot.connect(self.onFitDisplay)
365        if fit_dialog.exec_() == QtWidgets.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 list(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_() == QtWidgets.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 list(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 is 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 is not 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(QtWidgets.QDialog, PlotterWidget):
700    def __init__(self, parent=None, quickplot=False):
701
702        QtWidgets.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.