f9180803fe0ddb002c83e7ef2ee7f82a6a67860a
[hooke.git] / hooke / ui / gui / panel / plot.py
1 # Copyright (C) 2010 Massimo Sandal <devicerandom@gmail.com>
2 #                    Rolf Schmidt <rschmidt@alcor.concordia.ca>
3 #                    W. Trevor King <wking@drexel.edu>
4 #
5 # This file is part of Hooke.
6 #
7 # Hooke is free software: you can redistribute it and/or modify it
8 # under the terms of the GNU Lesser General Public License as
9 # published by the Free Software Foundation, either version 3 of the
10 # License, or (at your option) any later version.
11 #
12 # Hooke is distributed in the hope that it will be useful, but WITHOUT
13 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
14 # or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General
15 # Public License for more details.
16 #
17 # You should have received a copy of the GNU Lesser General Public
18 # License along with Hooke.  If not, see
19 # <http://www.gnu.org/licenses/>.
20
21 """Plot panel for Hooke.
22
23 Notes
24 -----
25 Originally based on `this example`_.
26
27 .. _this example:
28   http://matplotlib.sourceforge.net/examples/user_interfaces/embedding_in_wx2.html
29 """
30
31 import logging
32
33 import matplotlib
34 matplotlib.use('WXAgg')  # use wxpython with antigrain (agg) rendering
35 from matplotlib.backends.backend_wxagg import FigureCanvasWxAgg as FigureCanvas
36 from matplotlib.backends.backend_wx import NavigationToolbar2Wx as NavToolbar
37 from matplotlib.figure import Figure
38 from matplotlib.ticker import Formatter, ScalarFormatter
39 import numpy
40 import wx
41
42 from ....util.callback import callback, in_callback
43 from ....util.si import ppSI, split_data_label
44 from ..dialog.selection import Selection
45 from . import Panel
46
47
48 class HookeFormatter (Formatter):
49     """:class:`matplotlib.ticker.Formatter` using SI prefixes.
50     """
51     def __init__(self, unit='', decimals=2):
52         self.decimals = decimals
53         self.unit = unit
54
55     def __call__(self, x, pos=None):
56         """Return the format for tick val `x` at position `pos`.
57         """
58         if x == 0:
59             return '0'
60         return ppSI(value=x, unit=self.unit, decimals=self.decimals)
61
62
63 class HookeScalarFormatter (ScalarFormatter):
64     """:class:`matplotlib.ticker.ScalarFormatter` using only multiples
65     of three in the mantissa.
66
67     A fixed number of decimals can be displayed with the optional
68     parameter `decimals` . If `decimals` is `None` (default), the number
69     of decimals is defined from the current ticks.
70     """
71     def __init__(self, decimals=None, **kwargs):
72         # Can't use super() because ScalarFormatter is an old-style class :(.
73         ScalarFormatter.__init__(self, **kwargs)
74         self._decimals = decimals
75
76     def _set_orderOfMagnitude(self, *args, **kwargs):
77         """Sets the order of magnitude."""        
78         # Can't use super() because ScalarFormatter is an old-style class :(.
79         ScalarFormatter._set_orderOfMagnitude(self, *args, **kwargs)
80         self.orderOfMagnitude -= self.orderOfMagnitude % 3
81
82     def _set_format(self, *args, **kwargs):
83         """Sets the format string to format all ticklabels."""
84         # Can't use super() because ScalarFormatter is an old-style class :(.
85         ScalarFormatter._set_format(self, *args, **kwargs)
86         if self._decimals is None or self._decimals < 0:
87             locs = (np.asarray(self.locs)-self.offset) / 10**self.orderOfMagnitude+1e-15
88             sigfigs = [len(str('%1.8f'% loc).split('.')[1].rstrip('0')) \
89                    for loc in locs]
90             sigfigs.sort()
91             decimals = sigfigs[-1]
92         else:
93             decimals = self._decimals
94         self.format = '%1.' + str(decimals) + 'f'
95         if self._usetex:
96             self.format = '$%s$' % self.format
97         elif self._useMathText:
98             self.format = '$\mathdefault{%s}$' % self.format
99
100
101 class PlotPanel (Panel, wx.Panel):
102     """UI for graphical curve display.
103     """
104     def __init__(self, callbacks=None, **kwargs):
105         self.display_coordinates = False
106         self.style = 'line'
107         self._curve = None
108         self._config = {}
109         self._x_column = None
110         self._y_columns = []  # TODO: select right/left scales?
111         self._x_unit = ''
112         self._y_unit = ''
113         super(PlotPanel, self).__init__(
114             name='plot', callbacks=callbacks, **kwargs)
115         self._c = {}
116         self._c['figure'] = Figure()
117         self._c['canvas'] = FigureCanvas(
118             parent=self, id=wx.ID_ANY, figure=self._c['figure'])
119
120         self._set_color(wx.NamedColor('WHITE'))
121         sizer = wx.BoxSizer(wx.VERTICAL)
122         sizer.Add(self._c['canvas'], 1, wx.LEFT | wx.TOP | wx.GROW)
123         self._setup_toolbar(sizer=sizer)  # comment out to remove plot toolbar.
124         self.SetSizer(sizer)
125         self.Fit()
126
127         self.Bind(wx.EVT_SIZE, self._on_size) 
128         self._c['figure'].canvas.mpl_connect(
129             'button_press_event', self._on_click)
130         self._c['figure'].canvas.mpl_connect(
131             'axes_enter_event', self._on_enter_axes)
132         self._c['figure'].canvas.mpl_connect(
133             'axes_leave_event', self._on_leave_axes)
134         self._c['figure'].canvas.mpl_connect(
135             'motion_notify_event', self._on_mouse_move)
136
137     def _setup_toolbar(self, sizer):
138         self._c['toolbar'] = NavToolbar(self._c['canvas'])
139         self._c['x column'] = wx.Choice(
140             parent=self._c['toolbar'], choices=[])
141         self._c['x column'].SetToolTip(wx.ToolTip('x column'))
142         self._c['toolbar'].AddControl(self._c['x column'])
143         self._c['x column'].Bind(wx.EVT_CHOICE, self._on_x_column)
144         self._c['y column'] = wx.Button(
145             parent=self._c['toolbar'], label='y column(s)')
146         self._c['y column'].SetToolTip(wx.ToolTip('y column'))
147         self._c['toolbar'].AddControl(self._c['y column'])
148         self._c['y column'].Bind(wx.EVT_BUTTON, self._on_y_column)
149
150         self._c['toolbar'].Realize()  # call after putting items in the toolbar
151         if wx.Platform == '__WXMAC__':
152             # Mac platform (OSX 10.3, MacPython) does not seem to cope with
153             # having a toolbar in a sizer. This work-around gets the buttons
154             # back, but at the expense of having the toolbar at the top
155             self.SetToolBar(self._c['toolbar'])
156         elif wx.Platform == '__WXMSW__':
157             # On Windows platform, default window size is incorrect, so set
158             # toolbar width to figure width.
159             tw, th = self._c['toolbar'].GetSizeTuple()
160             fw, fh = self._c['canvas'].GetSizeTuple()
161             # By adding toolbar in sizer, we are able to put it at the bottom
162             # of the frame - so appearance is closer to GTK version.
163             # As noted above, doesn't work for Mac.
164             self._c['toolbar'].SetSize(wx.Size(fw, th))
165             sizer.Add(self._c['toolbar'], 0 , wx.LEFT | wx.EXPAND)
166         else:
167             sizer.Add(self._c['toolbar'], 0 , wx.LEFT | wx.EXPAND)
168         self._c['toolbar'].update()  # update the axes menu on the toolbar
169
170     def _set_color(self, rgbtuple=None):
171         """Set both figure and canvas colors to `rgbtuple`.
172         """
173         if rgbtuple == None:
174             rgbtuple = wx.SystemSettings.GetColour(wx.SYS_COLOUR_BTNFACE).Get()
175         col = [c/255.0 for c in rgbtuple]
176         self._c['figure'].set_facecolor(col)
177         self._c['figure'].set_edgecolor(col)
178         self._c['canvas'].SetBackgroundColour(wx.Colour(*rgbtuple))
179
180     def _set_status_text(self, text):
181         in_callback(self, text)
182
183     def _on_size(self, event):
184         event.Skip()
185         wx.CallAfter(self._resize_canvas)
186
187     def _on_click(self, event):
188         if self._curve == None:
189             return
190         d = self._config.get('plot decimals', 2)
191         x,y = (event.xdata, event.ydata)
192         if None in [x, y]:
193             return
194         xt = ppSI(value=x, unit=self._x_unit, decimals=d)
195         yt = ppSI(value=y, unit=self._y_unit, decimals=d)
196         point_indexes = []
197         for data in self._curve.data:
198             try:
199                 x_col = data.info['columns'].index(self._x_column)
200             except ValueError:
201                 continue  # data is missing a required column
202             index = numpy.absolute(data[:,x_col]-x).argmin()
203             point_indexes.append((data.info['name'], index))
204         self._set_status_text(
205             '(%s, %s) %s'
206             % (xt, yt,
207                ', '.join(['%s: %d' % (n,i) for n,i in point_indexes])))
208
209     def _on_enter_axes(self, event):
210         self.display_coordinates = True
211
212     def _on_leave_axes(self, event):
213         self.display_coordinates = False
214         #self.SetStatusText('')
215
216     def _on_mouse_move(self, event):
217         if 'toolbar' in self._c:
218             if event.guiEvent.m_shiftDown:
219                 self._c['toolbar'].set_cursor(wx.CURSOR_RIGHT_ARROW)
220             else:
221                 self._c['toolbar'].set_cursor(wx.CURSOR_ARROW)
222         if self.display_coordinates:
223             coordinateString = ''.join(
224                 ['x: ', str(event.xdata), ' y: ', str(event.ydata)])
225             #TODO: pretty format
226             #self.SetStatusText(coordinateString)
227
228     def _on_x_column(self, event):
229         self._x_column = self._c['x column'].GetStringSelection()
230         self.update()
231
232     def _on_y_column(self, event):
233         if not hasattr(self, '_columns') or len(self._columns) == 0:
234             self._y_columns = []
235             return
236         s = Selection(
237             options=self._columns,
238             message='Select visible y column(s).',
239             button_id=wx.ID_OK,
240             selection_style='multiple',
241             parent=self,
242             title='Select y column(s)',
243             style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
244         s.CenterOnScreen()
245         s.ShowModal()
246         if s.canceled == True:
247             return
248         self._y_columns = [self._columns[i] for i in s.selected]
249         s.Destroy()
250         if len(self._y_columns) == 0:
251             self._y_columns = self._columns[-1:]
252         self.update()
253
254     def _resize_canvas(self):
255         w,h = self.GetClientSize()
256         if 'toolbar' in self._c:
257             tw,th = self._c['toolbar'].GetSizeTuple()
258         else:
259             th = 0
260         dpi = float(self._c['figure'].get_dpi())
261         self._c['figure'].set_figwidth(w/dpi)
262         self._c['figure'].set_figheight((h-th)/dpi)
263         self._c['canvas'].draw()
264         self.Refresh()
265
266     def OnPaint(self, event):
267         print 'painting'
268         super(PlotPanel, self).OnPaint(event)
269         self._c['canvas'].draw()
270
271     def set_curve(self, curve, config=None):
272         self._curve = curve
273         columns = set()
274         for data in curve.data:
275             columns = columns.union(set(data.info['columns']))
276         self._columns = sorted(columns)
277         if self._x_column not in self._columns:
278             self._x_column = self._columns[0]
279         self._y_columns = [y for y in self._y_columns if y in self._columns]
280         if len(self._y_columns) == 0:
281             self._y_columns = self._columns[-1:]
282         if 'x column' in self._c:
283             for i in range(self._c['x column'].GetCount()):
284                 self._c['x column'].Delete(0)
285             self._c['x column'].AppendItems(self._columns)
286             self._c['x column'].SetStringSelection(self._x_column)
287         self.update(config=config)
288
289     def update(self, config=None):
290         if config == None:
291             config = self._config  # use the last cached value
292         else:
293             self._config = config  # cache for later refreshes
294         self._c['figure'].clear()
295         self._c['figure'].suptitle(
296             self._hooke_frame._file_name(self._curve.name),
297             fontsize=12)
298         axes = self._c['figure'].add_subplot(1, 1, 1)
299
300         if config['plot SI format'] == True:
301             d = config['plot decimals']
302             x_n, self._x_unit = split_data_label(self._x_column)
303             y_n, self._y_unit = split_data_label(self._y_columns[0])
304             for y_column in self._y_columns[1:]:
305                 y_n, y_unit = split_data_label(y_column)
306                 if y_unit != self._y_unit:
307                     log = logging.getLogger('hooke')
308                     log.warn('y-axes unit mismatch: %s != %s, using %s.'
309                              % (self._y_unit, y_unit, self._y_unit))
310             fx = HookeFormatter(decimals=d, unit=self._x_unit)
311             axes.xaxis.set_major_formatter(fx)
312             fy = HookeFormatter(decimals=d, unit=self._y_unit)
313             axes.yaxis.set_major_formatter(fy)
314             axes.set_xlabel(x_n)
315             if len(self._y_columns) == 1:
316                 axes.set_ylabel(y_n)
317         else:
318             self._x_unit = ''
319             self._y_unit = ''
320             axes.set_xlabel(self._x_column)
321             if len(self._y_columns) == 1:
322                 axes.set_ylabel(self._y_columns[0])
323
324         self._c['figure'].hold(True)
325         for i,data in enumerate(self._curve.data):
326             for y_column in self._y_columns:
327                 try:
328                     x_col = data.info['columns'].index(self._x_column)
329                     y_col = data.info['columns'].index(y_column)
330                 except ValueError:
331                     continue  # data is missing a required column
332                 axes.plot(data[:,x_col], data[:,y_col],
333                           '.',
334                           label=('%s, %s' % (data.info['name'], y_column)))
335         if config['plot legend'] == True:
336             axes.legend(loc='best')
337         self._c['canvas'].draw()