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

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

More Qt5 related fixes.

  • Property mode set to 100644
File size: 24.1 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        self.title(title=value.name)
54
55    def plot(self, data=None, color=None, marker=None, hide_error=False):
56        """
57        Add a new plot of self._data to the chart.
58        """
59        # Data1D
60        if isinstance(data, Data1D):
61            self.data = data
62        assert(self._data)
63
64        is_fit = (self.data.id=="fit")
65
66        # Transform data if required.
67        # TODO: it properly!
68        #if data.xtransform is not None or data.ytransform is not None:
69        #    a, b, c, d = GuiUtils.xyTransform(self.data, self.data.xtransform, self.data.ytransform)
70
71        # Shortcuts
72        ax = self.ax
73        x = self._data.view.x
74        y = self._data.view.y
75
76        # Marker symbol. Passed marker is one of matplotlib.markers characters
77        # Alternatively, picked up from Data1D as an int index of PlotUtilities.SHAPES dict
78        if marker is None:
79            marker = self.data.symbol
80            # Try name first
81            try:
82                marker = PlotUtilities.SHAPES[marker]
83            except KeyError:
84                marker = list(PlotUtilities.SHAPES.values())[marker]
85
86        assert marker is not None
87        # Plot name
88        if self.data.title:
89            self.title(title=self.data.title)
90        else:
91            self.title(title=self.data.name)
92
93        # Error marker toggle
94        if hide_error is None:
95            hide_error = self.data.hide_error
96
97        # Plot color
98        if color is None:
99            color = self.data.custom_color
100
101        color = PlotUtilities.getValidColor(color)
102
103        markersize = self._data.markersize
104
105        # Draw non-standard markers
106        l_width = markersize * 0.4
107        if marker == '-' or marker == '--':
108            line = self.ax.plot(x, y, color=color, lw=l_width, marker='',
109                             linestyle=marker, label=self._title, zorder=10)[0]
110
111        elif marker == 'vline':
112            y_min = min(y)*9.0/10.0 if min(y) < 0 else 0.0
113            line = self.ax.vlines(x=x, ymin=y_min, ymax=y, color=color,
114                            linestyle='-', label=self._title, lw=l_width, zorder=1)
115
116        elif marker == 'step':
117            line = self.ax.step(x, y, color=color, marker='', linestyle='-',
118                                label=self._title, lw=l_width, zorder=1)[0]
119
120        else:
121            # plot data with/without errorbars
122            if hide_error:
123                line = ax.plot(x, y, marker=marker, color=color, markersize=markersize,
124                        linestyle='', label=self._title, picker=True)
125            else:
126                line = ax.errorbar(x, y,
127                            yerr=self._data.view.dy, xerr=None,
128                            capsize=2, linestyle='',
129                            barsabove=False,
130                            color=color,
131                            marker=marker,
132                            markersize=markersize,
133                            lolims=False, uplims=False,
134                            xlolims=False, xuplims=False,
135                            label=self._title,
136                            picker=True)
137
138        # Update the list of data sets (plots) in chart
139        self.plot_dict[self._data.id] = self.data
140
141        # Now add the legend with some customizations.
142
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_idle()
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 list(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_() == QtWidgets.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_() == QtWidgets.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_() == QtWidgets.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        fit_dialog.updatePlot.connect(self.onFitDisplay)
361        if fit_dialog.exec_() == QtWidgets.QDialog.Accepted:
362            return
363
364    def replacePlot(self, id, new_plot):
365        """
366        Remove plot 'id' and add 'new_plot' to the chart.
367        This effectlvely refreshes the chart with changes to one of its plots
368        """
369        self.removePlot(id)
370        self.plot(data=new_plot)
371
372    def onRemovePlot(self, id):
373        """
374        Responds to the plot delete action
375        """
376        self.removePlot(id)
377
378        if len(self.plot_dict) == 0:
379            # last plot: graph is empty must be the panel must be destroyed
380                self.parent.close()
381
382    def removePlot(self, id):
383        """
384        Deletes the selected plot from the chart
385        """
386        if id not in list(self.plot_dict.keys()):
387            return
388
389        selected_plot = self.plot_dict[id]
390
391        plot_dict = copy.deepcopy(self.plot_dict)
392
393        # Labels might have been changed
394        xl = self.ax.xaxis.label.get_text()
395        yl = self.ax.yaxis.label.get_text()
396
397        self.plot_dict = {}
398
399        plt.cla()
400        self.ax.cla()
401
402        for ids in plot_dict:
403            if ids != id:
404                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)
405
406        # Reset the labels
407        self.ax.set_xlabel(xl)
408        self.ax.set_ylabel(yl)
409        self.canvas.draw()
410
411    def onFreeze(self, id):
412        """
413        Freezes the selected plot to a separate chart
414        """
415        plot = self.plot_dict[id]
416        self.manager.add_data(data_list=[plot])
417
418    def onModifyPlot(self, id):
419        """
420        Allows for MPL modifications to the selected plot
421        """
422        selected_plot = self.plot_dict[id]
423
424        # Old style color - single integer for enum color
425        # New style color - #hhhhhh
426        color = selected_plot.custom_color
427        # marker symbol and size
428        marker = selected_plot.symbol
429        marker_size = selected_plot.markersize
430        # plot name
431        legend = selected_plot.title
432
433        plotPropertiesWidget = PlotProperties(self,
434                                color=color,
435                                marker=marker,
436                                marker_size=marker_size,
437                                legend=legend)
438        if plotPropertiesWidget.exec_() == QtWidgets.QDialog.Accepted:
439            # Update Data1d
440            selected_plot.markersize = plotPropertiesWidget.markersize()
441            selected_plot.custom_color = plotPropertiesWidget.color()
442            selected_plot.symbol = plotPropertiesWidget.marker()
443            selected_plot.title = plotPropertiesWidget.legend()
444
445            # Redraw the plot
446            self.replacePlot(id, selected_plot)
447
448    def onToggleHideError(self, id):
449        """
450        Toggles hide error/show error menu item
451        """
452        selected_plot = self.plot_dict[id]
453        current = selected_plot.hide_error
454
455        # Flip the flag
456        selected_plot.hide_error = not current
457
458        plot_dict = copy.deepcopy(self.plot_dict)
459        self.plot_dict = {}
460
461        # Clean the canvas
462        plt.cla()
463        self.ax.cla()
464
465        # Recreate the plots but reverse the error flag for the current
466        for ids in plot_dict:
467            if ids == id:
468                self.plot(data=plot_dict[ids], hide_error=(not current))
469            else:
470                self.plot(data=plot_dict[ids], hide_error=plot_dict[ids].hide_error)               
471
472    def xyTransform(self, xLabel="", yLabel=""):
473        """
474        Transforms x and y in View and set the scale
475        """
476        # Transform all the plots on the chart
477        for id in list(self.plot_dict.keys()):
478            current_plot = self.plot_dict[id]
479            if current_plot.id == "fit":
480                self.removePlot(id)
481                continue
482
483            new_xlabel, new_ylabel, xscale, yscale =\
484                GuiUtils.xyTransform(current_plot, xLabel, yLabel)
485            self.xscale = xscale
486            self.yscale = yscale
487
488            # Plot the updated chart
489            self.removePlot(id)
490
491            # This assignment will wrap the label in Latex "$"
492            self.xLabel = new_xlabel
493            self.yLabel = new_ylabel
494            # Directly overwrite the data to avoid label reassignment
495            self._data = current_plot
496            self.plot()
497
498        pass # debug hook
499
500    def onFitDisplay(self, fit_data):
501        """
502        Add a linear fitting line to the chart
503        """
504        # Create new data structure with fitting result
505        tempx = fit_data[0]
506        tempy = fit_data[1]
507        self.fit_result.x = []
508        self.fit_result.y = []
509        self.fit_result.x = tempx
510        self.fit_result.y = tempy
511        self.fit_result.dx = None
512        self.fit_result.dy = None
513
514        #Remove another Fit, if exists
515        self.removePlot("fit")
516
517        self.fit_result.reset_view()
518        #self.offset_graph()
519
520        # Set plot properties
521        self.fit_result.id = 'fit'
522        self.fit_result.title = 'Fit'
523        self.fit_result.name = 'Fit'
524
525        # Plot the line
526        self.plot(data=self.fit_result, marker='-', hide_error=True)
527
528    def onMplMouseDown(self, event):
529        """
530        Left button down and ready to drag
531        """
532        # Check that the LEFT button was pressed
533        if event.button != 1:
534            return
535
536        self.leftdown = True
537        for text in self.textList:
538            if text.contains(event)[0]: # If user has clicked on text
539                self.selectedText = text
540                return
541        if event.inaxes is None:
542            return
543        try:
544            self.x_click = float(event.xdata)  # / size_x
545            self.y_click = float(event.ydata)  # / size_y
546        except:
547            self.position = None
548
549    def onMplMouseUp(self, event):
550        """
551        Set the data coordinates of the click
552        """
553        self.x_click = event.xdata
554        self.y_click = event.ydata
555
556        # Check that the LEFT button was released
557        if event.button == 1:
558            self.leftdown = False
559            self.selectedText = None
560
561        #release the legend
562        if self.gotLegend == 1:
563            self.gotLegend = 0
564
565    def onMplMouseMotion(self, event):
566        """
567        Check if the left button is press and the mouse in moving.
568        Compute delta for x and y coordinates and then perform the drag
569        """
570        if self.gotLegend == 1 and self.leftdown:
571            self.onLegendMotion(event)
572            return
573
574        #if self.leftdown and self.selectedText is not None:
575        if not self.leftdown or self.selectedText is None:
576            return
577        # User has clicked on text and is dragging
578        if event.inaxes is None:
579            # User has dragged outside of axes
580            self.selectedText = None
581        else:
582            # Only move text if mouse is within axes
583            self.selectedText.set_position((event.xdata, event.ydata))
584            self.canvas.draw_idle()
585        return
586
587    def onMplPick(self, event):
588        """
589        On pick legend
590        """
591        legend = self.legend
592        if event.artist != legend:
593            return
594        # Get the box of the legend.
595        bbox = self.legend.get_window_extent()
596        # Get mouse coordinates at time of pick.
597        self.mouse_x = event.mouseevent.x
598        self.mouse_y = event.mouseevent.y
599        # Get legend coordinates at time of pick.
600        self.legend_x = bbox.xmin
601        self.legend_y = bbox.ymin
602        # Indicate we picked up the legend.
603        self.gotLegend = 1
604
605        #self.legend.legendPatch.set_alpha(0.5)
606
607    def onLegendMotion(self, event):
608        """
609        On legend in motion
610        """
611        ax = event.inaxes
612        if ax is None:
613            return
614        # Event occurred inside a plotting area
615        lo_x, hi_x = ax.get_xlim()
616        lo_y, hi_y = ax.get_ylim()
617        # How much the mouse moved.
618        x = mouse_diff_x = self.mouse_x - event.x
619        y = mouse_diff_y = self.mouse_y - event.y
620        # Put back inside
621        if x < lo_x:
622            x = lo_x
623        if x > hi_x:
624            x = hi_x
625        if y < lo_y:
626            y = lo_y
627        if y > hi_y:
628            y = hi_y
629        # Move the legend from its previous location by that same amount
630        loc_in_canvas = self.legend_x - mouse_diff_x, \
631                        self.legend_y - mouse_diff_y
632        # Transform into legend coordinate system
633        trans_axes = self.legend.parent.transAxes.inverted()
634        loc_in_norm_axes = trans_axes.transform_point(loc_in_canvas)
635        self.legend_pos_loc = tuple(loc_in_norm_axes)
636        self.legend._loc = self.legend_pos_loc
637        # self.canvas.draw()
638        self.canvas.draw_idle()
639
640    def onMplWheel(self, event):
641        """
642        Process mouse wheel as zoom events
643        """
644        ax = event.inaxes
645        step = event.step
646
647        if ax is not None:
648            # Event occurred inside a plotting area
649            lo, hi = ax.get_xlim()
650            lo, hi = PlotUtilities.rescale(lo, hi, step,
651                              pt=event.xdata, scale=ax.get_xscale())
652            if not self.xscale == 'log' or lo > 0:
653                self._scale_xlo = lo
654                self._scale_xhi = hi
655                ax.set_xlim((lo, hi))
656
657            lo, hi = ax.get_ylim()
658            lo, hi = PlotUtilities.rescale(lo, hi, step, pt=event.ydata,
659                              scale=ax.get_yscale())
660            if not self.yscale == 'log' or lo > 0:
661                self._scale_ylo = lo
662                self._scale_yhi = hi
663                ax.set_ylim((lo, hi))
664        else:
665            # Check if zoom happens in the axes
666            xdata, ydata = None, None
667            x, y = event.x, event.y
668
669            for ax in self.axes:
670                insidex, _ = ax.xaxis.contains(event)
671                if insidex:
672                    xdata, _ = ax.transAxes.inverted().transform_point((x, y))
673                insidey, _ = ax.yaxis.contains(event)
674                if insidey:
675                    _, ydata = ax.transAxes.inverted().transform_point((x, y))
676            if xdata is not None:
677                lo, hi = ax.get_xlim()
678                lo, hi = PlotUtilities.rescale(lo, hi, step,
679                                  bal=xdata, scale=ax.get_xscale())
680                if not self.xscale == 'log' or lo > 0:
681                    self._scale_xlo = lo
682                    self._scale_xhi = hi
683                    ax.set_xlim((lo, hi))
684            if ydata is not None:
685                lo, hi = ax.get_ylim()
686                lo, hi = PlotUtilities.rescale(lo, hi, step, bal=ydata,
687                                  scale=ax.get_yscale())
688                if not self.yscale == 'log' or lo > 0:
689                    self._scale_ylo = lo
690                    self._scale_yhi = hi
691                    ax.set_ylim((lo, hi))
692        self.canvas.draw_idle()
693
694
695class Plotter(QtWidgets.QDialog, PlotterWidget):
696    def __init__(self, parent=None, quickplot=False):
697
698        QtWidgets.QDialog.__init__(self)
699        PlotterWidget.__init__(self, parent=self, manager=parent, quickplot=quickplot)
700        icon = QtGui.QIcon()
701        icon.addPixmap(QtGui.QPixmap(":/res/ball.ico"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
702        self.setWindowIcon(icon)
703
704
Note: See TracBrowser for help on using the repository browser.