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

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

Removed qtgui dependency on sasgui and wx SASVIEW-590

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