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