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