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

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

More Qt5 related fixes.

  • 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
310    def onRemoveText(self):
311        """
312        Remove the most recently added text
313        """
314        num_text = len(self.textList)
315        if num_text < 1:
316            return
317        txt = self.textList[num_text - 1]
318        text_remove = txt.get_text()
319        txt.remove()
320        self.textList.remove(txt)
321
322        self.canvas.draw_idle()
323
324    def onSetGraphRange(self):
325        """
326        Show a dialog allowing setting the chart ranges
327        """
328        # min and max of data
329        if self.setRange.exec_() == QtWidgets.QDialog.Accepted:
330            x_range = self.setRange.xrange()
331            y_range = self.setRange.yrange()
332            if x_range is not None and y_range is not None:
333                self.ax.set_xlim(x_range)
334                self.ax.set_ylim(y_range)
335                self.canvas.draw_idle()
336
337    def onResetGraphRange(self):
338        """
339        Resets the chart X and Y ranges to their original values
340        """
341        x_range = (self.data.x.min(), self.data.x.max())
342        y_range = (self.data.y.min(), self.data.y.max())
343        if x_range is not None and y_range is not None:
344            self.ax.set_xlim(x_range)
345            self.ax.set_ylim(y_range)
346            self.canvas.draw_idle()
347
348    def onLinearFit(self, id):
349        """
350        Creates and displays a simple linear fit for the selected plot
351        """
352        selected_plot = self.plot_dict[id]
353
354        maxrange = (min(selected_plot.x), max(selected_plot.x))
355        fitrange = self.ax.get_xlim()
356
357        fit_dialog = LinearFit(parent=self,
358                    data=selected_plot,
359                    max_range=maxrange,
360                    fit_range=fitrange,
361                    xlabel=self.xLogLabel,
362                    ylabel=self.yLogLabel)
363        fit_dialog.updatePlot.connect(self.onFitDisplay)
364        if fit_dialog.exec_() == QtWidgets.QDialog.Accepted:
365            return
366
367    def replacePlot(self, id, new_plot):
368        """
369        Remove plot 'id' and add 'new_plot' to the chart.
370        This effectlvely refreshes the chart with changes to one of its plots
371        """
372        self.removePlot(id)
373        self.plot(data=new_plot)
374
375    def onRemovePlot(self, id):
376        """
377        Responds to the plot delete action
378        """
379        self.removePlot(id)
380
381        if len(self.plot_dict) == 0:
382            # last plot: graph is empty must be the panel must be destroyed
383                self.parent.close()
384
385    def removePlot(self, id):
386        """
387        Deletes the selected plot from the chart
388        """
389        if id not in list(self.plot_dict.keys()):
390            return
391
392        selected_plot = self.plot_dict[id]
393
394        plot_dict = copy.deepcopy(self.plot_dict)
395
396        # Labels might have been changed
397        xl = self.ax.xaxis.label.get_text()
398        yl = self.ax.yaxis.label.get_text()
399
400        self.plot_dict = {}
401
402        plt.cla()
403        self.ax.cla()
404
405        for ids in plot_dict:
406            if ids != id:
407                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)
408
409        # Reset the labels
410        self.ax.set_xlabel(xl)
411        self.ax.set_ylabel(yl)
412        self.canvas.draw()
413
414    def onFreeze(self, id):
415        """
416        Freezes the selected plot to a separate chart
417        """
418        plot = self.plot_dict[id]
419        self.manager.add_data(data_list=[plot])
420
421    def onModifyPlot(self, id):
422        """
423        Allows for MPL modifications to the selected plot
424        """
425        selected_plot = self.plot_dict[id]
426
427        # Old style color - single integer for enum color
428        # New style color - #hhhhhh
429        color = selected_plot.custom_color
430        # marker symbol and size
431        marker = selected_plot.symbol
432        marker_size = selected_plot.markersize
433        # plot name
434        legend = selected_plot.title
435
436        plotPropertiesWidget = PlotProperties(self,
437                                color=color,
438                                marker=marker,
439                                marker_size=marker_size,
440                                legend=legend)
441        if plotPropertiesWidget.exec_() == QtWidgets.QDialog.Accepted:
442            # Update Data1d
443            selected_plot.markersize = plotPropertiesWidget.markersize()
444            selected_plot.custom_color = plotPropertiesWidget.color()
445            selected_plot.symbol = plotPropertiesWidget.marker()
446            selected_plot.title = plotPropertiesWidget.legend()
447
448            # Redraw the plot
449            self.replacePlot(id, selected_plot)
450
451    def onToggleHideError(self, id):
452        """
453        Toggles hide error/show error menu item
454        """
455        selected_plot = self.plot_dict[id]
456        current = selected_plot.hide_error
457
458        # Flip the flag
459        selected_plot.hide_error = not current
460
461        plot_dict = copy.deepcopy(self.plot_dict)
462        self.plot_dict = {}
463
464        # Clean the canvas
465        plt.cla()
466        self.ax.cla()
467
468        # Recreate the plots but reverse the error flag for the current
469        for ids in plot_dict:
470            if ids == id:
471                self.plot(data=plot_dict[ids], hide_error=(not current))
472            else:
473                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)               
474
475    def xyTransform(self, xLabel="", yLabel=""):
476        """
477        Transforms x and y in View and set the scale
478        """
479        # Transform all the plots on the chart
480        for id in list(self.plot_dict.keys()):
481            current_plot = self.plot_dict[id]
482            if current_plot.id == "fit":
483                self.removePlot(id)
484                continue
485
486            new_xlabel, new_ylabel, xscale, yscale =\
487                GuiUtils.xyTransform(current_plot, xLabel, yLabel)
488            self.xscale = xscale
489            self.yscale = yscale
490
491            # Plot the updated chart
492            self.removePlot(id)
493
494            # This assignment will wrap the label in Latex "$"
495            self.xLabel = new_xlabel
496            self.yLabel = new_ylabel
497            # Directly overwrite the data to avoid label reassignment
498            self._data = current_plot
499            self.plot()
500
501        pass # debug hook
502
503    def onFitDisplay(self, fit_data):
504        """
505        Add a linear fitting line to the chart
506        """
507        # Create new data structure with fitting result
508        tempx = fit_data[0]
509        tempy = fit_data[1]
510        self.fit_result.x = []
511        self.fit_result.y = []
512        self.fit_result.x = tempx
513        self.fit_result.y = tempy
514        self.fit_result.dx = None
515        self.fit_result.dy = None
516
517        #Remove another Fit, if exists
518        self.removePlot("fit")
519
520        self.fit_result.reset_view()
521        #self.offset_graph()
522
523        # Set plot properties
524        self.fit_result.id = 'fit'
525        self.fit_result.title = 'Fit'
526        self.fit_result.name = 'Fit'
527
528        # Plot the line
529        self.plot(data=self.fit_result, marker='-', hide_error=True)
530
531    def onMplMouseDown(self, event):
532        """
533        Left button down and ready to drag
534        """
535        # Check that the LEFT button was pressed
536        if event.button != 1:
537            return
538
539        self.leftdown = True
540        for text in self.textList:
541            if text.contains(event)[0]: # If user has clicked on text
542                self.selectedText = text
543                return
544        if event.inaxes is None:
545            return
546        try:
547            self.x_click = float(event.xdata)  # / size_x
548            self.y_click = float(event.ydata)  # / size_y
549        except:
550            self.position = None
551
552    def onMplMouseUp(self, event):
553        """
554        Set the data coordinates of the click
555        """
556        self.x_click = event.xdata
557        self.y_click = event.ydata
558
559        # Check that the LEFT button was released
560        if event.button == 1:
561            self.leftdown = False
562            self.selectedText = None
563
564        #release the legend
565        if self.gotLegend == 1:
566            self.gotLegend = 0
567
568    def onMplMouseMotion(self, event):
569        """
570        Check if the left button is press and the mouse in moving.
571        Compute delta for x and y coordinates and then perform the drag
572        """
573        if self.gotLegend == 1 and self.leftdown:
574            self.onLegendMotion(event)
575            return
576
577        #if self.leftdown and self.selectedText is not None:
578        if not self.leftdown or self.selectedText is None:
579            return
580        # User has clicked on text and is dragging
581        if event.inaxes is None:
582            # User has dragged outside of axes
583            self.selectedText = None
584        else:
585            # Only move text if mouse is within axes
586            self.selectedText.set_position((event.xdata, event.ydata))
587            self.canvas.draw_idle()
588        return
589
590    def onMplPick(self, event):
591        """
592        On pick legend
593        """
594        legend = self.legend
595        if event.artist != legend:
596            return
597        # Get the box of the legend.
598        bbox = self.legend.get_window_extent()
599        # Get mouse coordinates at time of pick.
600        self.mouse_x = event.mouseevent.x
601        self.mouse_y = event.mouseevent.y
602        # Get legend coordinates at time of pick.
603        self.legend_x = bbox.xmin
604        self.legend_y = bbox.ymin
605        # Indicate we picked up the legend.
606        self.gotLegend = 1
607
608        #self.legend.legendPatch.set_alpha(0.5)
609
610    def onLegendMotion(self, event):
611        """
612        On legend in motion
613        """
614        ax = event.inaxes
615        if ax is None:
616            return
617        # Event occurred inside a plotting area
618        lo_x, hi_x = ax.get_xlim()
619        lo_y, hi_y = ax.get_ylim()
620        # How much the mouse moved.
621        x = mouse_diff_x = self.mouse_x - event.x
622        y = mouse_diff_y = self.mouse_y - event.y
623        # Put back inside
624        if x < lo_x:
625            x = lo_x
626        if x > hi_x:
627            x = hi_x
628        if y < lo_y:
629            y = lo_y
630        if y > hi_y:
631            y = hi_y
632        # Move the legend from its previous location by that same amount
633        loc_in_canvas = self.legend_x - mouse_diff_x, \
634                        self.legend_y - mouse_diff_y
635        # Transform into legend coordinate system
636        trans_axes = self.legend.parent.transAxes.inverted()
637        loc_in_norm_axes = trans_axes.transform_point(loc_in_canvas)
638        self.legend_pos_loc = tuple(loc_in_norm_axes)
639        self.legend._loc = self.legend_pos_loc
640        # self.canvas.draw()
641        self.canvas.draw_idle()
642
643    def onMplWheel(self, event):
644        """
645        Process mouse wheel as zoom events
646        """
647        ax = event.inaxes
648        step = event.step
649
650        if ax is not None:
651            # Event occurred inside a plotting area
652            lo, hi = ax.get_xlim()
653            lo, hi = PlotUtilities.rescale(lo, hi, step,
654                              pt=event.xdata, scale=ax.get_xscale())
655            if not self.xscale == 'log' or lo > 0:
656                self._scale_xlo = lo
657                self._scale_xhi = hi
658                ax.set_xlim((lo, hi))
659
660            lo, hi = ax.get_ylim()
661            lo, hi = PlotUtilities.rescale(lo, hi, step, pt=event.ydata,
662                              scale=ax.get_yscale())
663            if not self.yscale == 'log' or lo > 0:
664                self._scale_ylo = lo
665                self._scale_yhi = hi
666                ax.set_ylim((lo, hi))
667        else:
668            # Check if zoom happens in the axes
669            xdata, ydata = None, None
670            x, y = event.x, event.y
671
672            for ax in self.axes:
673                insidex, _ = ax.xaxis.contains(event)
674                if insidex:
675                    xdata, _ = ax.transAxes.inverted().transform_point((x, y))
676                insidey, _ = ax.yaxis.contains(event)
677                if insidey:
678                    _, ydata = ax.transAxes.inverted().transform_point((x, y))
679            if xdata is not None:
680                lo, hi = ax.get_xlim()
681                lo, hi = PlotUtilities.rescale(lo, hi, step,
682                                  bal=xdata, scale=ax.get_xscale())
683                if not self.xscale == 'log' or lo > 0:
684                    self._scale_xlo = lo
685                    self._scale_xhi = hi
686                    ax.set_xlim((lo, hi))
687            if ydata is not None:
688                lo, hi = ax.get_ylim()
689                lo, hi = PlotUtilities.rescale(lo, hi, step, bal=ydata,
690                                  scale=ax.get_yscale())
691                if not self.yscale == 'log' or lo > 0:
692                    self._scale_ylo = lo
693                    self._scale_yhi = hi
694                    ax.set_ylim((lo, hi))
695        self.canvas.draw_idle()
696
697
698class Plotter(QtWidgets.QDialog, PlotterWidget):
699    def __init__(self, parent=None, quickplot=False):
700
701        QtWidgets.QDialog.__init__(self)
702        PlotterWidget.__init__(self, parent=self, manager=parent, quickplot=quickplot)
703        icon = QtGui.QIcon()
704        icon.addPixmap(QtGui.QPixmap(":/res/ball.ico"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
705        self.setWindowIcon(icon)
706
707
Note: See TracBrowser for help on using the repository browser.