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

Last change on this file since e0ed8a8 was e0ed8a8, checked in by Adam Washington <adam.washington@…>, 7 years ago

Draw Sesans data in linear coordinates

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