def export_csv(self, rows):
        dialog = wx.FileDialog(self.parent.Parent, 'Export to CSV...', wildcard='CSV files (*.csv)|*.csv', style=wx.FD_SAVE)
        if dialog.ShowModal() == wx.ID_OK:
            if self.timeline.start_background_task('Exporting to "%s"' % dialog.Path):
                wx.CallAfter(wx.GetApp().GetTopWindow().StatusBar.gauge.Show)
                
                export_series = set()
                for plot in self._plot_paths:
                    for path in plot:
                        export_series.add(path)
    
                if self._x_view is None:
                    self._x_view = (0.0, (self.timeline.end_stamp - self.timeline.start_stamp).to_sec())
    
                self._csv_path       = dialog.Path
                self._csv_row_stride = rows
    
                self._csv_data_loader = PlotDataLoader(self.timeline, self._topic)
                self._csv_data_loader.add_complete_listener(self._csv_data_loaded)
                self._csv_data_loader.paths = export_series
                self._csv_data_loader.set_interval(self.timeline.start_stamp + rospy.Duration.from_sec(max(0.01, self._x_view[0])),
                                                   self.timeline.start_stamp + rospy.Duration.from_sec(max(0.01, self._x_view[1])))
                self._csv_data_loader.start()

        dialog.Destroy()
class PlotView(TopicMessageView):
    name = 'Plot'

    rows = [(   1, 'All'),
            (   2, 'Every 2nd message'),
            (   3, 'Every 3rd message'),
            (   4, 'Every 4th message'),
            (   5, 'Every 5th message'),
            (  10, 'Every 10th message'),
            (  20, 'Every 20th message'),
            (  50, 'Every 50th message'),
            ( 100, 'Every 100th message'),
            (1000, 'Every 1000th message')]

    def __init__(self, timeline, parent):
        TopicMessageView.__init__(self, timeline, parent)

        self._topic           = None
        self._message         = None
        self._plot_paths      = []
        self._playhead        = None
        self._chart           = Chart()
        self._data_loader     = None
        self._x_view          = None
        self._dirty_count     = 0
        self._csv_data_loader = None
        self._csv_path        = None
        self._csv_row_stride  = None

        self._clicked_pos = None
        self._dragged_pos = None

        self._configure_frame = None

        self._max_interval_pixels = 1.0

        tb = self.parent.ToolBar
        icons_dir = roslib.packages.get_pkg_dir(PKG) + '/icons/'
        tb.AddSeparator()
        tb.Bind(wx.EVT_TOOL, lambda e: self.configure(), tb.AddLabelTool(wx.ID_ANY, '', wx.Bitmap(icons_dir + 'cog.png')))

        self.parent.Bind(wx.EVT_SIZE,        self._on_size)
        self.parent.Bind(wx.EVT_PAINT,       self._on_paint)
        self.parent.Bind(wx.EVT_LEFT_DOWN,   self._on_left_down)
        self.parent.Bind(wx.EVT_MIDDLE_DOWN, self._on_middle_down)
        self.parent.Bind(wx.EVT_RIGHT_DOWN,  self._on_right_down)
        self.parent.Bind(wx.EVT_LEFT_UP,     self._on_left_up)
        self.parent.Bind(wx.EVT_MIDDLE_UP,   self._on_middle_up)
        self.parent.Bind(wx.EVT_RIGHT_UP,    self._on_right_up)
        self.parent.Bind(wx.EVT_MOTION,      self._on_mouse_move)
        self.parent.Bind(wx.EVT_MOUSEWHEEL,  self._on_mousewheel)
        self.parent.Bind(wx.EVT_CLOSE,       self._on_close)

        wx.CallAfter(self.configure)

    ## TopicMessageView implementation

    def message_viewed(self, bag, msg_details):
        TopicMessageView.message_viewed(self, bag, msg_details)

        topic, msg, t = msg_details

        if not self._data_loader:
            self._topic = topic
            self.start_loading()

        self._message = msg

        self.playhead = (t - self.timeline.start_stamp).to_sec()

    def message_cleared(self):
        self._message = None
        
        TopicMessageView.message_cleared(self)
        
        wx.CallAfter(self.parent.Refresh)

    def timeline_changed(self):
        # If timeline end_stamp is within the plot view, then invalidate the data loader
        if self._x_view is not None: 
            end_elapsed = (self.timeline.end_stamp - self.timeline.start_stamp).to_sec()
            if end_elapsed > self._x_view[0] and end_elapsed < self._x_view[1] and self._data_loader:
                self._data_loader.invalidate()

        wx.CallAfter(self.parent.Refresh)

    # property: plot_paths

    def _get_plot_paths(self): return self._plot_paths

    def _set_plot_paths(self, plot_paths):
        self._plot_paths = plot_paths

        # Update the data loader with the paths to plot
        if self._data_loader:
            paths = []
            for plot in self._plot_paths:
                for path in plot:
                    if path not in paths:
                        paths.append(path)

            self._data_loader.paths = paths

        # Update the chart with the new areas
        self._chart.create_areas(self._plot_paths)

        self._update_max_interval()
        
        wx.CallAfter(self.parent.Refresh)

    plot_paths = property(_get_plot_paths, _set_plot_paths)

    # property: playhead

    def _get_playhead(self): return self._playhead

    def _set_playhead(self, playhead):
        self._playhead = playhead
        
        # Check if playhead is visible. If not, then move the view region.
        if self._x_view is not None:
            if self._playhead < self._x_view[0]:
                x_view = self._x_view[1] - self._x_view[0]
                self._x_view = (self._playhead, self._playhead + x_view)
                self._update_data_loader_interval()

            elif self._playhead > self._x_view[1]:
                x_view = self._x_view[1] - self._x_view[0]
                self._x_view = (self._playhead - x_view, self._playhead)
                self._update_data_loader_interval()
        
        wx.CallAfter(self.parent.Refresh)

    playhead = property(_get_playhead, _set_playhead)

    def _update_max_interval(self):
        if not self._data_loader:
            return

        if len(self._chart.areas) > 0:
            secs_per_px = (self._data_loader.end_stamp - self._data_loader.start_stamp).to_sec() / self._chart._width
            self._data_loader.max_interval = secs_per_px * self._max_interval_pixels

    ## Events

    def _on_paint(self, event):
        if not self._data_loader or len(self._chart._areas) == 0:
            return

        self._update_chart_info()

        dc = wx.lib.wxcairo.ContextFromDC(wx.PaintDC(self.parent))

        self._chart.paint(dc)

    def _update_chart_info(self):
        for area_index, plot in enumerate(self._plot_paths):
            area = self._chart.areas[area_index]

            area.x_view = self._x_view
            
            if self._message is not None:
                area.x_indicator = self._playhead
            else:
                area.x_indicator = None

            area.x_range = (0.0, (self.timeline.end_stamp - self.timeline.start_stamp).to_sec())
            
            if self._data_loader.is_load_complete:
                area.data_alpha = 1.0
            else:
                area.data_alpha = 0.5

            area._series_list = plot

            data = {}
            for plot_path in plot:
                if plot_path in self._data_loader._data:
                    data[plot_path] = self._data_loader._data[plot_path]
            area._series_data = data

    def _on_size(self, event):
        self._chart.set_size(self.parent.ClientSize)

        self._update_max_interval()

    def _on_left_down(self, event):
        self._clicked_pos = self._dragged_pos = event.Position
        if len(self._chart.areas) > 0 and self._chart.areas[0].view_min_x is not None and self._chart.areas[0].view_max_x is not None:
            self.timeline.playhead = self.timeline.start_stamp + rospy.Duration.from_sec(max(0.01, self._chart.areas[0].x_chart_to_data(event.Position[0])))

    def _on_middle_down(self, event):
        self._clicked_pos = self._dragged_pos = event.Position

    def _on_right_down(self, event):
        self._clicked_pos = self._dragged_pos = event.Position
        self.parent.PopupMenu(PlotPopupMenu(self.parent, self), self._clicked_pos)

    def _on_left_up  (self, event): self._on_mouse_up(event)
    def _on_middle_up(self, event): self._on_mouse_up(event)
    def _on_right_up (self, event): self._on_mouse_up(event)

    def _on_mouse_up(self, event): self.parent.Cursor = wx.StockCursor(wx.CURSOR_ARROW)
    
    def _on_mouse_move(self, event):
        x, y = event.Position
        
        if event.Dragging():
            if event.MiddleIsDown() or event.ShiftDown():
                # Middle or shift: zoom
                
                dx_click, dy_click = x - self._clicked_pos[0], y - self._clicked_pos[1]
                dx_drag,  dy_drag  = x - self._dragged_pos[0], y - self._dragged_pos[1]

                if dx_drag != 0 and len(self._chart.areas) > 0:
                    dsecs = self._chart.areas[0].dx_chart_to_data(dx_drag)  # assuming areas share x axis
                    self._translate_plot(dsecs)

                if dy_drag != 0:
                    self._zoom_plot(1.0 + 0.005 * dy_drag)

                self._update_data_loader_interval()

                wx.CallAfter(self.parent.Refresh)

                self.parent.Cursor = wx.StockCursor(wx.CURSOR_HAND)

            elif event.LeftIsDown():
                if len(self._chart.areas) > 0 and self._chart.areas[0].view_min_x is not None and self._chart.areas[0].view_max_x is not None:
                    self.timeline.playhead = self.timeline.start_stamp + rospy.Duration.from_sec(max(0.01, self._chart.areas[0].x_chart_to_data(x)))

                wx.CallAfter(self.parent.Refresh)
            
            self._dragged_pos = event.Position

    def _on_mousewheel(self, event):
        dz = event.WheelRotation / event.WheelDelta
        self._zoom_plot(1.0 - dz * 0.2)

        self._update_data_loader_interval()

        wx.CallAfter(self.parent.Refresh)

    def _on_close(self, event):
        if self._configure_frame:
            self._configure_frame.Close()

        self.stop_loading()

        event.Skip()

    ##

    def _update_data_loader_interval(self):
        self._data_loader.set_interval(self.timeline.start_stamp + rospy.Duration.from_sec(max(0.01, self._x_view[0])),
                                       self.timeline.start_stamp + rospy.Duration.from_sec(max(0.01, self._x_view[1])))

    def _zoom_plot(self, zoom):
        if self._x_view is None:
            self._x_view = (0.0, (self.timeline.end_stamp - self.timeline.start_stamp).to_sec())

        x_view_interval = self._x_view[1] - self._x_view[0]
        if x_view_interval == 0.0:
            return

        playhead_fraction = (self._playhead - self._x_view[0]) / x_view_interval

        new_x_view_interval = zoom * x_view_interval

        # Enforce zoom limits (0.1s, 1.5 * range)
        max_zoom_interval = (self.timeline.end_stamp - self.timeline.start_stamp).to_sec() * 1.5
        if new_x_view_interval > max_zoom_interval:
            new_x_view_interval = max_zoom_interval
        elif new_x_view_interval < 0.1:
            new_x_view_interval = 0.1

        interval_0 = self._playhead - playhead_fraction * new_x_view_interval
        interval_1 = interval_0 + new_x_view_interval

        timeline_range = (self.timeline.end_stamp - self.timeline.start_stamp).to_sec()
        interval_0 = min(interval_0, timeline_range - 0.1)
        interval_1 = max(interval_1, 0.1)

        self._x_view = (interval_0, interval_1)

        self._update_max_interval()

    def _translate_plot(self, dsecs):
        if self._x_view is None:
            self._x_view = (0.0, (self.timeline.end_stamp - self.timeline.start_stamp).to_sec())

        new_start = self._x_view[0] - dsecs
        new_end   = self._x_view[1] - dsecs

        timeline_range = (self.timeline.end_stamp - self.timeline.start_stamp).to_sec()
        new_start = min(new_start, timeline_range - 0.1)
        new_end   = max(new_end,   0.1)

        self._x_view = (new_start, new_end)

    ##

    def stop_loading(self):
        if self._data_loader:
            self._data_loader.stop()
            self._data_loader = None

    def start_loading(self):
        if self._topic and not self._data_loader:
            self._data_loader = PlotDataLoader(self.timeline, self._topic)
            self._data_loader.add_progress_listener(self._data_loader_updated)
            self._data_loader.add_complete_listener(self._data_loader_complete)
            self._data_loader.start()

    def _data_loader_updated(self):
        self._dirty_count += 1
        if self._dirty_count > 5:
            wx.CallAfter(self.parent.Refresh)
            self._dirty_count = 0

    def _data_loader_complete(self):
        wx.CallAfter(self.parent.Refresh)

    def configure(self):
        if self._configure_frame is not None or self._message is None:
            return

        self._configure_frame = PlotConfigureFrame(self)

        frame = self.parent.TopLevelParent
        self._configure_frame.Position = (frame.Position[0] + frame.Size[0] + 10, frame.Position[1])
        self._configure_frame.Show()

    ## Export to CSV...

    def export_csv(self, rows):
        dialog = wx.FileDialog(self.parent.Parent, 'Export to CSV...', wildcard='CSV files (*.csv)|*.csv', style=wx.FD_SAVE)
        if dialog.ShowModal() == wx.ID_OK:
            if self.timeline.start_background_task('Exporting to "%s"' % dialog.Path):
                wx.CallAfter(wx.GetApp().GetTopWindow().StatusBar.gauge.Show)
                
                export_series = set()
                for plot in self._plot_paths:
                    for path in plot:
                        export_series.add(path)
    
                if self._x_view is None:
                    self._x_view = (0.0, (self.timeline.end_stamp - self.timeline.start_stamp).to_sec())
    
                self._csv_path       = dialog.Path
                self._csv_row_stride = rows
    
                self._csv_data_loader = PlotDataLoader(self.timeline, self._topic)
                self._csv_data_loader.add_complete_listener(self._csv_data_loaded)
                self._csv_data_loader.paths = export_series
                self._csv_data_loader.set_interval(self.timeline.start_stamp + rospy.Duration.from_sec(max(0.01, self._x_view[0])),
                                                   self.timeline.start_stamp + rospy.Duration.from_sec(max(0.01, self._x_view[1])))
                self._csv_data_loader.start()

        dialog.Destroy()

    def _csv_data_loaded(self):
        # Collate data
        i = 0
        series_dict = {}
        unique_stamps = set()
        for series in self._csv_data_loader._data:
            d = {}
            series_dict[series] = d
            point_num = 0
            for x, y in self._csv_data_loader._data[series].points:
                if point_num % self._csv_row_stride == 0:
                    d[x] = y
                    unique_stamps.add(x)
                point_num += 1
            i += 1
        series_columns = sorted(series_dict.keys())

        try:
            csv_writer = csv.DictWriter(open(self._csv_path, 'w'), ['elapsed'] + series_columns)
 
            # Write header row
            header_dict = { 'elapsed' : 'elapsed' }
            for column in series_columns:
                header_dict[column] = column            
            csv_writer.writerow(header_dict)

            # Initialize progress monitoring
            progress = 0
            def update_progress(v):
                wx.GetApp().TopWindow.StatusBar.progress = v
            total_stamps = len(unique_stamps)
            stamp_num = 0
            
            # Write data
            for stamp in sorted(unique_stamps):
                if self.timeline.background_task_cancel:
                    break
                
                row = { 'elapsed' : stamp }
                for column in series_dict:
                    if stamp in series_dict[column]:
                        row[column] = series_dict[column][stamp]

                csv_writer.writerow(row)

                new_progress = int(100.0 * (float(stamp_num) / total_stamps))
                if new_progress != progress:
                    progress = new_progress
                    wx.CallAfter(update_progress, progress)

                stamp_num += 1

        except Exception, ex:
            print >> sys.stderr, 'Error writing to CSV file: %s' % str(ex)

        # Hide progress monitoring
        if not self.timeline.background_task_cancel:
            wx.CallAfter(wx.GetApp().TopWindow.StatusBar.gauge.Hide)

        self.timeline.stop_background_task()
 def start_loading(self):
     if self._topic and not self._data_loader:
         self._data_loader = PlotDataLoader(self.timeline, self._topic)
         self._data_loader.add_progress_listener(self._data_loader_updated)
         self._data_loader.add_complete_listener(self._data_loader_complete)
         self._data_loader.start()