/*
 * Copyright (c) 2009, Swedish Institute of Computer Science.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 * 3. Neither the name of the Institute nor the names of its contributors
 *    may be used to endorse or promote products derived from this software
 *    without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE INSTITUTE AND CONTRIBUTORS ``AS IS'' AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED.  IN NO EVENT SHALL THE INSTITUTE OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
 * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
 * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
 * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
 * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
 * SUCH DAMAGE.
 *
 * $Id: TimeLine.java,v 1.30 2010/10/19 20:31:02 fros4943 Exp $
 */

package se.sics.cooja.plugins;

import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Observable;
import java.util.Observer;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.Box;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComboBox;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JFileChooser;
import javax.swing.JMenu;
import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JScrollPane;
import javax.swing.JSlider;
import javax.swing.JSplitPane;
import javax.swing.JToolTip;
import javax.swing.KeyStroke;
import javax.swing.Popup;
import javax.swing.PopupFactory;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;

import org.apache.log4j.Logger;
import org.jdom.Element;

import se.sics.cooja.ClassDescription;
import se.sics.cooja.GUI;
import se.sics.cooja.HasQuickHelp;
import se.sics.cooja.Mote;
import se.sics.cooja.Plugin;
import se.sics.cooja.PluginType;
import se.sics.cooja.SimEventCentral.MoteCountListener;
import se.sics.cooja.Simulation;
import se.sics.cooja.VisPlugin;
import se.sics.cooja.Watchpoint;
import se.sics.cooja.WatchpointMote;
import se.sics.cooja.WatchpointMote.WatchpointListener;
import se.sics.cooja.interfaces.LED;
import se.sics.cooja.interfaces.Radio;
import se.sics.cooja.interfaces.Radio.RadioEvent;
import se.sics.cooja.motes.AbstractEmulatedMote;

/**
 * Shows events such as mote logs, LEDs, and radio transmissions, in a timeline.
 *
 * @author Fredrik Osterlind
 */
@ClassDescription("Timeline")
@PluginType(PluginType.SIM_STANDARD_PLUGIN)
public class TimeLine extends VisPlugin implements HasQuickHelp {
  private static final long serialVersionUID = -883154261246961973L;
  public static final int LED_PIXEL_HEIGHT = 2;
  public static final int EVENT_PIXEL_HEIGHT = 4;
  public static final int TIME_MARKER_PIXEL_HEIGHT = 6;
  public static final int FIRST_MOTE_PIXEL_OFFSET = TIME_MARKER_PIXEL_HEIGHT + EVENT_PIXEL_HEIGHT;

  private static final Color COLOR_BACKGROUND = Color.WHITE;
  private static final boolean PAINT_ZERO_WIDTH_EVENTS = true;
  private static final int TIMELINE_UPDATE_INTERVAL = 100;

  private double currentPixelDivisor = 200;

  private static final long[] ZOOM_LEVELS = {
  	1, 2, 5, 10,
  	20, 50, 100, 200, 500, 1000,
  	2000, 5000, 10000, 20000, 50000, 100000 };

  private boolean needZoomOut = false;

  private static Logger logger = Logger.getLogger(TimeLine.class);

  private int paintedMoteHeight = EVENT_PIXEL_HEIGHT;

  private Simulation simulation;
  private MoteCountListener newMotesListener;

  private JScrollPane timelineScrollPane;
  private MoteRuler timelineMoteRuler;
  private JComponent timeline;
  private Box eventCheckboxes;
  private JSplitPane splitPane;

  private Observer moteHighlightObserver = null;
  private ArrayList<Mote> highlightedMotes = new ArrayList<Mote>();
  private final static Color HIGHLIGHT_COLOR = Color.CYAN;

  private ArrayList<MoteObservation> activeMoteObservers = new ArrayList<MoteObservation>();

  private ArrayList<MoteEvents> allMoteEvents = new ArrayList<MoteEvents>();

  private boolean showRadioRXTX = true;
  private boolean showRadioChannels = false;
  private boolean showRadioHW = true;
  private boolean showLEDs = true;
  private boolean showLogOutputs = false;
  private boolean showWatchpoints = false;

  private Point popupLocation = null;

  /**
   * @param simulation Simulation
   * @param gui GUI
   */
  public TimeLine(final Simulation simulation, final GUI gui) {
    super("Timeline", gui);
    this.simulation = simulation;

    currentPixelDivisor = ZOOM_LEVELS[ZOOM_LEVELS.length/2];

    /* Menus */
    JMenuBar menuBar = new JMenuBar();
    JMenu fileMenu = new JMenu("File");
    JMenu editMenu = new JMenu("Edit");
    JMenu motesMenu = new JMenu("Motes");
    JMenu eventsMenu = new JMenu("Events");
    JMenu viewMenu = new JMenu("View");
    JMenu zoomMenu = new JMenu("Zoom");

    menuBar.add(fileMenu);
    menuBar.add(editMenu);
    menuBar.add(viewMenu);
    menuBar.add(zoomMenu);
    menuBar.add(eventsMenu);
    menuBar.add(motesMenu);

    this.setJMenuBar(menuBar);

    motesMenu.add(new JMenuItem(addMoteAction));
    zoomMenu.add(new JMenuItem(zoomInAction));
    zoomMenu.add(new JMenuItem(zoomOutAction));
    zoomMenu.add(new JMenuItem(zoomSliderAction));
    viewMenu.add(new JCheckBoxMenuItem(executionDetailsAction) {
	    private static final long serialVersionUID = 8314556794750277113L;
	    public boolean isSelected() {
      		return executionDetails;
	    }
    });
    viewMenu.add(new JCheckBoxMenuItem(radioChannelsAction) {
	    private static final long serialVersionUID = 6830282466652559714L;
	    public boolean isSelected() {
      		return radioChannels;
	    }
    });

    fileMenu.add(new JMenuItem(saveDataAction));
    fileMenu.add(new JMenuItem(statisticsAction));
    editMenu.add(new JMenuItem(clearAction));

    JCheckBox eventCheckBox;
    eventCheckBox = createEventCheckbox("Radio traffic", "Show radio transmissions, receptions, and collisions");
    eventCheckBox.setSelected(showRadioRXTX);
    eventCheckBox.setName("showRadioRXTX");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showRadioRXTX = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    eventsMenu.add(eventCheckBox);
    eventCheckBox = createEventCheckbox("Radio channel", "Show different radio channels");
    eventCheckBox.setSelected(showRadioChannels);
    eventCheckBox.setName("showRadioChannels");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showRadioChannels = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    /*eventCheckboxes.add(eventCheckBox);*/
    eventsMenu.add(eventCheckBox);
    eventCheckBox = createEventCheckbox("Radio state", "Show radio hardware state");
    eventCheckBox.setSelected(showRadioHW);
    eventCheckBox.setName("showRadioHW");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showRadioHW = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    eventsMenu.add(eventCheckBox);
    eventCheckBox = createEventCheckbox("LEDs", "Show LED state");
    eventCheckBox.setSelected(showLEDs);
    eventCheckBox.setName("showLEDs");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showLEDs = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    eventsMenu.add(eventCheckBox);
    eventCheckBox = createEventCheckbox("Log output", "Show mote log output, such as by printf()'s");
    eventCheckBox.setSelected(showLogOutputs);
    eventCheckBox.setName("showLogOutput");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showLogOutputs = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    /*eventCheckboxes.add(eventCheckBox);*/
    eventCheckBox = createEventCheckbox("Watchpoints", "Show code watchpoints (for MSPSim-based motes)");
    eventCheckBox.setSelected(showWatchpoints);
    eventCheckBox.setName("showWatchpoints");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showWatchpoints = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    eventsMenu.add(eventCheckBox);

    /* Box: events to observe */

    eventCheckboxes = Box.createVerticalBox();
    /*
    eventCheckboxes.add(new JButton(addMoteAction));
    eventCheckboxes.add(new JSeparator());

    JCheckBox eventCheckBox;
    eventCheckBox = createEventCheckbox("Radio RX/TX", "Show radio transmissions, receptions, and collisions");
    eventCheckBox.setSelected(showRadioRXTX);
    eventCheckBox.setName("showRadioRXTX");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showRadioRXTX = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    eventCheckboxes.add(eventCheckBox);
    eventCheckBox = createEventCheckbox("Radio channels", "Show different radio channels");
    eventCheckBox.setSelected(showRadioChannels);
    eventCheckBox.setName("showRadioChannels");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showRadioChannels = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });

    eventCheckBox = createEventCheckbox("Radio ON/OFF", "Show radio hardware state");
    eventCheckBox.setSelected(showRadioHW);
    eventCheckBox.setName("showRadioHW");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showRadioHW = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    eventCheckboxes.add(eventCheckBox);
    eventCheckBox = createEventCheckbox("LEDs", "Show LED state");
    eventCheckBox.setSelected(showLEDs);
    eventCheckBox.setName("showLEDs");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showLEDs = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    eventCheckboxes.add(eventCheckBox);
    eventCheckBox = createEventCheckbox("Log output", "Show mote log output, such as by printf()'s");
    eventCheckBox.setSelected(showLogOutputs);
    eventCheckBox.setName("showLogOutput");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showLogOutputs = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });

    eventCheckBox = createEventCheckbox("Watchpoints", "Show code watchpoints (for MSPSim-based motes)");
    eventCheckBox.setSelected(showWatchpoints);
    eventCheckBox.setName("showWatchpoints");
    eventCheckBox.addActionListener(new ActionListener() {
      public void actionPerformed(ActionEvent e) {
        showWatchpoints = ((JCheckBox) e.getSource()).isSelected();
        recalculateMoteHeight();
      }
    });
    eventCheckboxes.add(eventCheckBox);
    */
    /* Panel: timeline canvas w. scroll pane and add mote button */
    timeline = new Timeline();
    timelineScrollPane = new JScrollPane(
        timeline,
        JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,
        JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS);
    timelineScrollPane.getHorizontalScrollBar().setUnitIncrement(50);

    timelineMoteRuler = new MoteRuler();
    timelineScrollPane.setRowHeaderView(timelineMoteRuler);
    timelineScrollPane.setBackground(Color.WHITE);

    splitPane = new JSplitPane(
        JSplitPane.HORIZONTAL_SPLIT,
        new JScrollPane(eventCheckboxes),
        timelineScrollPane
    );
    splitPane.setOneTouchExpandable(true);

    /* Zoom in/out via keyboard*/
    getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_PLUS, KeyEvent.CTRL_DOWN_MASK), "zoomIn");
    getActionMap().put("zoomIn", zoomInAction);
    getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, KeyEvent.CTRL_DOWN_MASK), "zoomOut");
    getActionMap().put("zoomOut", zoomOutAction);

    /*    getContentPane().add(splitPane);*/
    getContentPane().add(timelineScrollPane);

    recalculateMoteHeight();
    pack();

    numberMotesWasUpdated();

    /* Automatically add/delete motes */
    simulation.getEventCentral().addMoteCountListener(newMotesListener = new MoteCountListener() {
      public void moteWasAdded(Mote mote) {
        addMote(mote);
      }
      public void moteWasRemoved(Mote mote) {
        removeMote(mote);
      }
    });
    for (Mote m: simulation.getMotes()) {
      addMote(m);
    }

    /* Update timeline for the duration of the plugin */
    repaintTimelineTimer.start();

    gui.addMoteHighlightObserver(moteHighlightObserver = new Observer() {
      public void update(Observable obs, Object obj) {
        if (!(obj instanceof Mote)) {
          return;
        }

        final Timer timer = new Timer(100, null);
        final Mote mote = (Mote) obj;
        timer.addActionListener(new ActionListener() {
          public void actionPerformed(ActionEvent e) {
            /* Count down */
            if (timer.getDelay() < 90) {
              timer.stop();
              highlightedMotes.remove(mote);
              repaint();
              return;
            }

            /* Toggle highlight state */
            if (highlightedMotes.contains(mote)) {
              highlightedMotes.remove(mote);
            } else {
              highlightedMotes.add(mote);
            }
            timer.setDelay(timer.getDelay()-1);
            repaint();
          }
        });
        timer.start();
      }
    });

    /* XXX HACK: here we set the position and size of the window when it appears on a blank simulation screen. */
    this.setLocation(0, gui.getDesktopPane().getHeight() - 166);
    this.setSize(gui.getDesktopPane().getWidth(), 166);
  }

  private JCheckBox createEventCheckbox(String text, String tooltip) {
    JCheckBox checkBox = new JCheckBox(text, true);
    checkBox.setToolTipText(tooltip);
    return checkBox;
  }

  private Action removeMoteAction = new AbstractAction() {
    private static final long serialVersionUID = 2924285037480429045L;
    public void actionPerformed(ActionEvent e) {
      JComponent b = (JComponent) e.getSource();
      Mote m = (Mote) b.getClientProperty("mote");
      removeMote(m);
    }
  };
  private Action removeAllOtherMotesAction = new AbstractAction() {
  	private static final long serialVersionUID = 2924285037480429045L;
  	public void actionPerformed(ActionEvent e) {
  		JComponent b = (JComponent) e.getSource();
  		Mote m = (Mote) b.getClientProperty("mote");
  		MoteEvents[] mes = allMoteEvents.toArray(new MoteEvents[0]);
  		for (MoteEvents me: mes) {
  			if (me.mote == m) {
  				continue;
  			}
  			removeMote(me.mote);
  		}
  	}
  };
  private Action sortMoteAction = new AbstractAction() {
    private static final long serialVersionUID = 621116674700872058L;
    public void actionPerformed(ActionEvent e) {
      JComponent b = (JComponent) e.getSource();
      Mote m = (Mote) b.getClientProperty("mote");

      /* Sort by distance */
      ArrayList<MoteEvents> sortedMoteEvents = new ArrayList<MoteEvents>();
      for (MoteEvents me: allMoteEvents.toArray(new MoteEvents[0])) {
        double d = me.mote.getInterfaces().getPosition().getDistanceTo(m);

        int i=0;
        for (i=0; i < sortedMoteEvents.size(); i++) {
          double d2 = m.getInterfaces().getPosition().getDistanceTo(sortedMoteEvents.get(i).mote);
          if (d < d2) {
            break;
          }
        }
        sortedMoteEvents.add(i, me);

      }
      allMoteEvents = sortedMoteEvents;
      numberMotesWasUpdated();
    }
  };
  private Action topMoteAction = new AbstractAction() {
		private static final long serialVersionUID = 4683178751482241843L;
		public void actionPerformed(ActionEvent e) {
  		JComponent b = (JComponent) e.getSource();
  		Mote m = (Mote) b.getClientProperty("mote");

  		/* Sort by distance */
  		MoteEvents mEvent = null;
  		for (MoteEvents me: allMoteEvents.toArray(new MoteEvents[0])) {
  			if (me.mote == m) {
  				mEvent = me;
  				break;
  			}
  		}
  		allMoteEvents.remove(mEvent);
  		allMoteEvents.add(0, mEvent);
  		numberMotesWasUpdated();
  	}
  };
  private Action addMoteAction = new AbstractAction("Show motes...") {
    private static final long serialVersionUID = 7546685285707302865L;
    public void actionPerformed(ActionEvent e) {

      JComboBox source = new JComboBox();
      source.addItem("All motes");
      for (Mote m: simulation.getMotes()) {
        source.addItem(m);
      }
      Object description[] = {
          source
      };
      JOptionPane optionPane = new JOptionPane();
      optionPane.setMessage(description);
      optionPane.setMessageType(JOptionPane.QUESTION_MESSAGE);
      String options[] = new String[] {"Cancel", "Show"};
      optionPane.setOptions(options);
      optionPane.setInitialValue(options[1]);
      JDialog dialog = optionPane.createDialog(GUI.getTopParentContainer(), "Show mote in timeline");
      dialog.setVisible(true);

      if (optionPane.getValue() == null || !optionPane.getValue().equals("Show")) {
        return;
      }

      if (source.getSelectedItem().equals("All motes")) {
        for (Mote m: simulation.getMotes()) {
          addMote(m);
        }
      } else {
        addMote((Mote) source.getSelectedItem());
      }
    }
  };

  private void forceRepaintAndFocus(final long focusTime, final double focusCenter) {
    forceRepaintAndFocus(focusTime, focusCenter, true);
  }

  private void forceRepaintAndFocus(final long focusTime, final double focusCenter, final boolean mark) {
    lastRepaintSimulationTime = -1; /* Force repaint */
    repaintTimelineTimer.getActionListeners()[0].actionPerformed(null); /* Force size update*/
    SwingUtilities.invokeLater(new Runnable() {
      public void run() {
        int w = timeline.getVisibleRect().width;

        /* centerPixel-leftPixel <=> focusCenter*w; */
        int centerPixel = (int) (focusTime/currentPixelDivisor);
        int leftPixel = (int) (focusTime/currentPixelDivisor - focusCenter*w);

        Rectangle r = new Rectangle(
            leftPixel, 0,
            w, 1
        );

        timeline.scrollRectToVisible(r);

        /* Time ruler */
        if (mark) {
          mousePixelPositionX = centerPixel;
          mouseDownPixelPositionX = centerPixel;
          mousePixelPositionY = timeline.getHeight();
        }
      }
    });
  }

  private Action zoomInAction = new AbstractAction("Zoom in (Ctrl+)") {
    private static final long serialVersionUID = -2592452356547803615L;
    public void actionPerformed(ActionEvent e) {
      Rectangle r = timeline.getVisibleRect();
      int pixelX = r.x + r.width/2;
      if (popupLocation != null) {
        pixelX = popupLocation.x;
        popupLocation = null;
      }
      if (mousePixelPositionX > 0) {
        pixelX = mousePixelPositionX;
      }
      final long centerTime = (long) (pixelX*currentPixelDivisor);

      int zoomLevel = 0;
      while (zoomLevel < ZOOM_LEVELS.length) {
        if (currentPixelDivisor <= ZOOM_LEVELS[zoomLevel]) break;
        zoomLevel++;
      }

      if (zoomLevel > 0) {
        zoomLevel--; /* zoom in */
      }
      currentPixelDivisor = ZOOM_LEVELS[zoomLevel];
      logger.info("Zoom level: " + currentPixelDivisor + " microseconds/pixel " + ((zoomLevel==0)?"(MIN)":""));

      forceRepaintAndFocus(centerTime, 0.5);
    }
  };

  private Action zoomOutAction = new AbstractAction("Zoom out (Ctrl-)") {
    private static final long serialVersionUID = 6837091379835151725L;
    public void actionPerformed(ActionEvent e) {
      Rectangle r = timeline.getVisibleRect();
      int pixelX = r.x + r.width/2;
      if (popupLocation != null) {
        pixelX = popupLocation.x;
        popupLocation = null;
      }
      final long centerTime = (long) (pixelX*currentPixelDivisor);
      if (mousePixelPositionX > 0) {
        pixelX = mousePixelPositionX;
      }

      int zoomLevel = 0;
      while (zoomLevel < ZOOM_LEVELS.length) {
        if (currentPixelDivisor <= ZOOM_LEVELS[zoomLevel]) break;
        zoomLevel++;
      }

      if (zoomLevel < ZOOM_LEVELS.length-1) {
        zoomLevel++; /* zoom out */
      }
      currentPixelDivisor = ZOOM_LEVELS[zoomLevel];
      logger.info("Zoom level: " + currentPixelDivisor + " microseconds/pixel " + ((zoomLevel==ZOOM_LEVELS.length-1)?"(MAX)":""));

      forceRepaintAndFocus(centerTime, 0.5);
    }
  };

  private Action zoomSliderAction = new AbstractAction("Zoom slider (Ctrl+Mouse)") {
    private static final long serialVersionUID = -4288046377707363837L;
    public void actionPerformed(ActionEvent e) {
      int zoomLevel = 0;
      while (zoomLevel < ZOOM_LEVELS.length) {
        if (currentPixelDivisor <= ZOOM_LEVELS[zoomLevel]) break;
        zoomLevel++;
      }

      final JSlider zoomSlider = new JSlider(JSlider.VERTICAL, 0, ZOOM_LEVELS.length-1, zoomLevel);
      zoomSlider.setInverted(true);
      zoomSlider.setPaintTicks(true);
      zoomSlider.setPaintLabels(false);

      final long centerTime = (long) (popupLocation.x*currentPixelDivisor);

      zoomSlider.addChangeListener(new ChangeListener() {
        public void stateChanged(ChangeEvent e) {
          int zoomLevel = zoomSlider.getValue();

          currentPixelDivisor = ZOOM_LEVELS[zoomLevel];
          logger.info("Zoom level: " + currentPixelDivisor + " microseconds/pixel " + ((zoomLevel==ZOOM_LEVELS.length-1)?"(MAX)":""));

          forceRepaintAndFocus(centerTime, 0.5);
        }
      });

      final JPopupMenu zoomPopup = new JPopupMenu();
      zoomPopup.add(zoomSlider);
      zoomPopup.show(TimeLine.this, TimeLine.this.getWidth()/2, 0);
      zoomSlider.requestFocus();
    }
  };

  /**
   * Save logged raw data to file for post-processing.
   */
  private Action saveDataAction = new AbstractAction("Save to file...") {
    private static final long serialVersionUID = 975176793514425718L;
    public void actionPerformed(ActionEvent e) {
      JFileChooser fc = new JFileChooser();
      int returnVal = fc.showSaveDialog(GUI.getTopParentContainer());
      if (returnVal != JFileChooser.APPROVE_OPTION) {
        return;
      }
      File saveFile = fc.getSelectedFile();

      if (saveFile.exists()) {
        String s1 = "Overwrite";
        String s2 = "Cancel";
        Object[] options = { s1, s2 };
        int n = JOptionPane.showOptionDialog(
            GUI.getTopParentContainer(),
            "A file with the same name already exists.\nDo you want to remove it?",
            "Overwrite existing file?", JOptionPane.YES_NO_OPTION,
            JOptionPane.QUESTION_MESSAGE, null, options, s1);
        if (n != JOptionPane.YES_OPTION) {
          return;
        }
      }

      if (saveFile.exists() && !saveFile.canWrite()) {
        logger.fatal("No write access to file");
        return;
      }

      try {
        BufferedWriter outStream = new BufferedWriter(
            new OutputStreamWriter(
                new FileOutputStream(
                    saveFile)));

        /* Output all events (sorted per mote) */
        for (MoteEvents moteEvents: allMoteEvents) {
          for (MoteEvent ev: moteEvents.ledEvents) {
            outStream.write(moteEvents.mote + "\t" + ev.time + "\t" + ev.toString() + "\n");
          }
          for (MoteEvent ev: moteEvents.logEvents) {
            outStream.write(moteEvents.mote + "\t" + ev.time + "\t" + ev.toString() + "\n");
          }
          for (MoteEvent ev: moteEvents.radioChannelEvents) {
            outStream.write(moteEvents.mote + "\t" + ev.time + "\t" + ev.toString() + "\n");
          }
          for (MoteEvent ev: moteEvents.radioHWEvents) {
            outStream.write(moteEvents.mote + "\t" + ev.time + "\t" + ev.toString() + "\n");
          }
          for (MoteEvent ev: moteEvents.radioRXTXEvents) {
            outStream.write(moteEvents.mote + "\t" + ev.time + "\t" + ev.toString() + "\n");
          }
          for (MoteEvent ev: moteEvents.watchpointEvents) {
            outStream.write(moteEvents.mote + "\t" + ev.time + "\t" + ev.toString() + "\n");
          }
        }

        outStream.close();
      } catch (Exception ex) {
        logger.fatal("Could not write to file: " + saveFile);
        return;
      }

    }
  };

  private Action clearAction = new AbstractAction("Clear all timeline data") {
    private static final long serialVersionUID = -4592530582786872403L;
    public void actionPerformed(ActionEvent e) {
      if (simulation.isRunning()) {
        simulation.invokeSimulationThread(new Runnable() {
          public void run() {
            clear();
          }
        });
      } else {
        clear();
      }
    }
  };

  public void clear() {
    for (MoteEvents me : allMoteEvents) {
      me.clear();
    }
    repaint();
  }


  private class MoteStatistics {
    Mote mote;
    long onTimeRedLED = 0, onTimeGreenLED = 0, onTimeBlueLED = 0;
    int nrLogs = 0;
    long radioOn = 0;
    long onTimeRX = 0, onTimeTX = 0, onTimeInterfered = 0;

    public String toString() {
      return toString(true, true, true, true);
    }
    public String toString(boolean logs, boolean leds, boolean radioHW, boolean radioRXTX) {
      long duration = simulation.getSimulationTime(); /* XXX */
      StringBuilder sb = new StringBuilder();
      String moteDesc = (mote!=null?"" + mote.getID():"AVERAGE") + " ";
      if (logs) {
        sb.append(moteDesc + "nr_logs " + nrLogs + "\n");
      }
      if (leds) {
        sb.append(moteDesc + "led_red " + onTimeRedLED + " us " + 100.0*((double)onTimeRedLED/duration) + " %\n");
        sb.append(moteDesc + "led_green " + onTimeGreenLED + " us " + 100.0*((double)onTimeGreenLED/duration) + " %\n");
        sb.append(moteDesc + "led_blue " + onTimeBlueLED + " us " + 100.0*((double)onTimeBlueLED/duration) + " %\n");
      }
      if (radioHW) {
        sb.append(moteDesc + "radio_on " + radioOn + " us " + 100.0*((double)radioOn/duration) + " %\n");
      }
      if (radioRXTX) {
        sb.append(moteDesc + "radio_tx " + onTimeTX + " us " + 100.0*((double)onTimeTX/duration) + " %\n");
        sb.append(moteDesc + "radio_rx " + onTimeRX + " us " + 100.0*((double)onTimeRX/duration) + " %\n");
        sb.append(moteDesc + "radio_int " + onTimeInterfered + " us " + 100.0*((double)onTimeInterfered/duration) + " %\n");
      }
      return sb.toString();
    }
  }
  private Action statisticsAction = new AbstractAction("Print statistics to console") {
    private static final long serialVersionUID = 8671605486913497397L;
    public void actionPerformed(ActionEvent e) {
      if (simulation.isRunning()) {
        simulation.stopSimulation();
      }
      logger.info(extractStatistics());
    }
  };

  public String extractStatistics() {
    return extractStatistics(true, true, true, true);
  }
  public synchronized String extractStatistics(
      boolean logs, boolean leds, boolean radioHW, boolean radioRXTX) {
    StringBuilder output = new StringBuilder();

    /* Process all events (per mote basis) */
    ArrayList<MoteStatistics> allStats = new ArrayList<MoteStatistics>();
    for (MoteEvents moteEvents: allMoteEvents) {
      MoteStatistics stats = new MoteStatistics();
      allStats.add(stats);
      stats.mote = moteEvents.mote;

      if (leds) {
        for (MoteEvent ev: moteEvents.ledEvents) {
          if (!(ev instanceof LEDEvent)) continue;
          LEDEvent ledEvent = (LEDEvent) ev;

          /* Red */
          if (ledEvent.red) {
            /* LED is on, add time interval */
            if (ledEvent.next == null) {
              stats.onTimeRedLED += (simulation.getSimulationTime() - ledEvent.time);
            } else {
              stats.onTimeRedLED += (ledEvent.next.time - ledEvent.time);
            }
          }

          /* Green */
          if (ledEvent.green) {
            /* LED is on, add time interval */
            if (ledEvent.next == null) {
              stats.onTimeGreenLED += (simulation.getSimulationTime() - ledEvent.time);
            } else {
              stats.onTimeGreenLED += (ledEvent.next.time - ledEvent.time);
            }
          }

          /* Blue */
          if (ledEvent.blue) {
            /* LED is on, add time interval */
            if (ledEvent.next == null) {
              stats.onTimeBlueLED += (simulation.getSimulationTime() - ledEvent.time);
            } else {
              stats.onTimeBlueLED += (ledEvent.next.time - ledEvent.time);
            }
          }
        }
      }

      if (logs) {
        for (MoteEvent ev: moteEvents.logEvents) {
          if (!(ev instanceof LogEvent)) continue;
          stats.nrLogs++;
        }
      }

      /* TODO Radio channels */

      if (radioHW) {
        for (MoteEvent ev: moteEvents.radioHWEvents) {
          if (!(ev instanceof RadioHWEvent)) continue;
          RadioHWEvent hwEvent = (RadioHWEvent) ev;
          if (hwEvent.on) {
            /* HW is on */
            if (hwEvent.next == null) {
              stats.radioOn += (simulation.getSimulationTime() - hwEvent.time);
            } else {
              stats.radioOn += (hwEvent.next.time - hwEvent.time);
            }
          }
        }
      }

      if (radioRXTX) {
        for (MoteEvent ev: moteEvents.radioRXTXEvents) {
          if (!(ev instanceof RadioRXTXEvent)) continue;
          RadioRXTXEvent rxtxEvent = (RadioRXTXEvent) ev;
          if (rxtxEvent.state == RXTXRadioEvent.IDLE) {
            continue;
          }

          long diff;
          if (rxtxEvent.next == null) {
            diff = (simulation.getSimulationTime() - rxtxEvent.time);
          } else {
            diff = (rxtxEvent.next.time - rxtxEvent.time);
          }

          if (rxtxEvent.state == RXTXRadioEvent.TRANSMITTING) {
            stats.onTimeTX += diff;
            continue;
          }
          if (rxtxEvent.state == RXTXRadioEvent.INTERFERED) {
            stats.onTimeInterfered += diff;
            continue;
          }
          if (rxtxEvent.state == RXTXRadioEvent.RECEIVING) {
            stats.onTimeRX += diff;
            continue;
          }
        }
      }

      /* TODO Watchpoints */

      output.append(stats.toString(logs, leds, radioHW, radioRXTX));
    }

    if (allStats.size() == 0) {
      return output.toString();
    }

    /* Average */
    MoteStatistics average = new MoteStatistics();
    for (MoteStatistics stats: allStats) {
      average.onTimeRedLED += stats.onTimeRedLED;
      average.onTimeGreenLED += stats.onTimeGreenLED;
      average.onTimeBlueLED += stats.onTimeBlueLED;
      average.radioOn += stats.radioOn;
      average.onTimeRX += stats.onTimeRX;
      average.onTimeTX += stats.onTimeTX;
      average.onTimeInterfered += stats.onTimeInterfered;
    }
    average.onTimeBlueLED /= allStats.size();
    average.onTimeGreenLED /= allStats.size();
    average.onTimeBlueLED /= allStats.size();
    average.radioOn /= allStats.size();
    average.onTimeRX /= allStats.size();
    average.onTimeTX /= allStats.size();
    average.onTimeInterfered /= allStats.size();

    output.append(average.toString(logs, leds, radioHW, radioRXTX));
    return output.toString();
  }

  public void trySelectTime(final long toTime) {
    java.awt.EventQueue.invokeLater(new Runnable() {
      public void run() {
        /* Mark selected time in time ruler */
        final int toPixel = (int) (toTime / currentPixelDivisor);
        mousePixelPositionX = toPixel;
        mouseDownPixelPositionX = toPixel;
        mousePixelPositionY = timeline.getHeight();

        /* Check if time is already visible */
        Rectangle vis = timeline.getVisibleRect();
        if (toPixel >= vis.x && toPixel < vis.x + vis.width) {
          repaint();
          return;
        }

        forceRepaintAndFocus(toTime, 0.5, false);
      }
    });
  }

  private Action radioLoggerAction = new AbstractAction("Show in Radio Logger") {
    private static final long serialVersionUID = 7690116136861949864L;
    public void actionPerformed(ActionEvent e) {
      if (popupLocation == null) {
        return;
      }
      long time = (long) (popupLocation.x*currentPixelDivisor);

      Plugin[] plugins = simulation.getGUI().getStartedPlugins();
      for (Plugin p: plugins) {
      	if (!(p instanceof RadioLogger)) {
      		continue;
      	}

        /* Select simulation time */
      	RadioLogger plugin = (RadioLogger) p;
        plugin.trySelectTime(time);
      }
    }
  };
  private Action logListenerAction = new AbstractAction("Show in Log Listener") {
    private static final long serialVersionUID = -8626118368774023257L;
    public void actionPerformed(ActionEvent e) {
      if (popupLocation == null) {
        return;
      }
      long time = (long) (popupLocation.x*currentPixelDivisor);

      Plugin[] plugins = simulation.getGUI().getStartedPlugins();
      for (Plugin p: plugins) {
      	if (!(p instanceof LogListener)) {
      		continue;
      	}

        /* Select simulation time */
        LogListener plugin = (LogListener) p;
        plugin.trySelectTime(time);
      }
    }
  };

  private Action showInAllAction = new AbstractAction("Show in log listener and radio logger") {
    private static final long serialVersionUID = -2458733078524773995L;
    public void actionPerformed(ActionEvent e) {
      logListenerAction.actionPerformed(null);
      radioLoggerAction.actionPerformed(null);
    }
  };

  private boolean executionDetails = false;
  private boolean radioChannels = false;
  private Action executionDetailsAction = new AbstractAction("Show execution details in tooltips") {
    private static final long serialVersionUID = -8626118368774023257L;
    public void actionPerformed(ActionEvent e) {
    	executionDetails = !executionDetails;
    }
  };
  private Action radioChannelsAction = new AbstractAction("Color radio state by active radio channel") {
    private static final long serialVersionUID = -8626118368774023257L;
    public void actionPerformed(ActionEvent e) {
    	radioChannels = !radioChannels;
    	repaint();
    }
  };

  private void numberMotesWasUpdated() {
    /* Plugin title */
    if (allMoteEvents.isEmpty()) {
      setTitle("Timeline");
    } else {
      setTitle("Timeline showing " + allMoteEvents.size() + " motes");
    }
    timelineMoteRuler.revalidate();
    timelineMoteRuler.repaint();
    timeline.revalidate();
    timeline.repaint();
  }

  /* XXX Keeps track of observed mote interfaces */
  class MoteObservation {
    private Observer observer;
    private Observable observable;
    private Mote mote;

    private WatchpointMote watchpointMote; /* XXX */
    private WatchpointListener watchpointListener; /* XXX */

    public MoteObservation(Mote mote, Observable observable, Observer observer) {
      this.mote = mote;
      this.observable = observable;
      this.observer = observer;
    }

    /* XXX Special case, should be generalized */
    public MoteObservation(Mote mote, WatchpointMote watchpointMote, WatchpointListener listener) {
      this.mote = mote;
      this.watchpointMote = watchpointMote;
      this.watchpointListener = listener;
    }

    public Mote getMote() {
      return mote;
    }

    /**
     * Disconnect observer from observable (stop observing) and clean up resources (remove pointers).
     */
    public void dispose() {
      if (observable != null) {
        observable.deleteObserver(observer);
        mote = null;
        observable = null;
        observer = null;
      }

      /* XXX */
      if (watchpointMote != null) {
        watchpointMote.removeWatchpointListener(watchpointListener);
        watchpointMote = null;
        watchpointListener = null;
      }
    }
  }

  private void addMoteObservers(final Mote mote, final MoteEvents moteEvents) {
    /* TODO Log: final Log moteLog = mote.getInterfaces().getLog(); */
    /* TODO Unknown state event */

    /* LEDs */
    final LED moteLEDs = mote.getInterfaces().getLED();
    if (moteLEDs != null) {
      LEDEvent startupEv = new LEDEvent(
          simulation.getSimulationTime(),
          moteLEDs.isRedOn(),
          moteLEDs.isGreenOn(),
          moteLEDs.isYellowOn()
      );
      moteEvents.addLED(startupEv);
      Observer observer = new Observer() {
        public void update(Observable o, Object arg) {
          LEDEvent ev = new LEDEvent(
              simulation.getSimulationTime(),
              moteLEDs.isRedOn(),
              moteLEDs.isGreenOn(),
              moteLEDs.isYellowOn()
          );

          moteEvents.addLED(ev);
        }
      };

      moteLEDs.addObserver(observer);
      activeMoteObservers.add(new MoteObservation(mote, moteLEDs, observer));
    }

    /* Radio HW, RXTX */
    final Radio moteRadio = mote.getInterfaces().getRadio();
    if (moteRadio != null) {
      RadioHWEvent startupHW = new RadioHWEvent(
          simulation.getSimulationTime(), moteRadio.isRadioOn());
      if (radioChannels) {
      	startupHW.channel = moteRadio.getChannel();
      }
      moteEvents.addRadioHW(startupHW);
      RadioRXTXEvent startupRXTX = new RadioRXTXEvent(
          simulation.getSimulationTime(), RXTXRadioEvent.IDLE);
      moteEvents.addRadioRXTX(startupRXTX);
      Observer observer = new Observer() {
      	int lastChannel = -1;
      	public void update(Observable o, Object arg) {
          /* Radio HW events */
        	if (radioChannels && moteRadio.getLastEvent() == RadioEvent.UNKNOWN) {
        		int nowChannel = moteRadio.getChannel();
        		if (nowChannel == lastChannel) {
        			return;
        		}
      			lastChannel = nowChannel;

            RadioHWEvent ev = new RadioHWEvent(
                simulation.getSimulationTime(), moteRadio.isRadioOn());
            if (radioChannels) {
            	ev.channel = moteRadio.getChannel();
            }

            moteEvents.addRadioHW(ev);

            if (executionDetails && mote instanceof AbstractEmulatedMote) {
            	String details = ((AbstractEmulatedMote) mote).getExecutionDetails();
            	if (details != null) {
            		details = "<br>" + details.replace("\n", "<br>");
                ev.details = details;
            	}
            }
            return;
        	}

        	if (moteRadio.getLastEvent() == RadioEvent.HW_ON ||
              moteRadio.getLastEvent() == RadioEvent.HW_OFF) {
            RadioHWEvent ev = new RadioHWEvent(
                simulation.getSimulationTime(), moteRadio.isRadioOn());
            if (radioChannels) {
            	ev.channel = moteRadio.getChannel();
            }

            moteEvents.addRadioHW(ev);

            if (executionDetails && mote instanceof AbstractEmulatedMote) {
            	String details = ((AbstractEmulatedMote) mote).getExecutionDetails();
            	if (details != null) {
            		details = "<br>" + details.replace("\n", "<br>");
                ev.details = details;
            	}
            }
            return;
          }

          /* Radio RXTX events */
          RadioEvent radioEv = moteRadio.getLastEvent();
          if (radioEv == RadioEvent.TRANSMISSION_STARTED ||
              radioEv == RadioEvent.TRANSMISSION_FINISHED ||
              radioEv == RadioEvent.RECEPTION_STARTED ||
              radioEv == RadioEvent.RECEPTION_INTERFERED ||
              radioEv == RadioEvent.RECEPTION_FINISHED) {

            RadioRXTXEvent ev;
            /* Override events, instead show state */
            if (moteRadio.isTransmitting()) {
              ev = new RadioRXTXEvent(
                  simulation.getSimulationTime(), RXTXRadioEvent.TRANSMITTING);
            } else if (!moteRadio.isRadioOn()) {
              ev = new RadioRXTXEvent(
                  simulation.getSimulationTime(), RXTXRadioEvent.IDLE);
            } else if (moteRadio.isInterfered()) {
              ev = new RadioRXTXEvent(
                  simulation.getSimulationTime(), RXTXRadioEvent.INTERFERED);
            } else if (moteRadio.isReceiving()) {
              ev = new RadioRXTXEvent(
                  simulation.getSimulationTime(), RXTXRadioEvent.RECEIVING);
            } else {
              ev = new RadioRXTXEvent(
                  simulation.getSimulationTime(), RXTXRadioEvent.IDLE);
            }

            moteEvents.addRadioRXTX(ev);

            if (executionDetails && mote instanceof AbstractEmulatedMote) {
            	String details = ((AbstractEmulatedMote) mote).getExecutionDetails();
            	if (details != null) {
            		details = "<br>" + details.replace("\n", "<br>");
                ev.details = details;
            	}
            }

            return;
          }

        }
      };

      moteRadio.addObserver(observer);
      activeMoteObservers.add(new MoteObservation(mote, moteRadio, observer));
    }

    /* XXX Experimental: Watchpoints */
    if (mote instanceof WatchpointMote) {
      final WatchpointMote watchpointMote = ((WatchpointMote)mote);
      WatchpointListener listener = new WatchpointListener() {
        public void watchpointTriggered(Watchpoint watchpoint) {
          WatchpointEvent ev = new WatchpointEvent(simulation.getSimulationTime(), watchpoint);

          if (executionDetails && mote instanceof AbstractEmulatedMote) {
            String details = ((AbstractEmulatedMote) mote).getExecutionDetails();
            if (details != null) {
              details = "<br>" + details.replace("\n", "<br>");
              ev.details = details;
            }
          }

          moteEvents.addWatchpoint(ev);
        }
        public void watchpointsChanged() {
        }
      };

      watchpointMote.addWatchpointListener(listener);
      activeMoteObservers.add(new MoteObservation(mote, watchpointMote, listener));
    }

  }

  private void addMote(Mote newMote) {
    if (newMote == null) {
      return;
    }
    for (MoteEvents moteEvents: allMoteEvents) {
      if (moteEvents.mote == newMote) {
        return;
      }
    }

    MoteEvents newMoteLog = new MoteEvents(newMote);
    allMoteEvents.add(newMoteLog);
    addMoteObservers(newMote, newMoteLog);

    numberMotesWasUpdated();
  }

  private void removeMote(Mote mote) {
    MoteEvents remove = null;
    for (MoteEvents moteEvents: allMoteEvents) {
      if (moteEvents.mote == mote) {
        remove = moteEvents;
        break;
      }
    }
    if (remove == null) {
      logger.warn("No such observed mote: " + mote);
      return;
    }
    allMoteEvents.remove(remove);

    /* Remove mote observers */
    MoteObservation[] moteObservers = activeMoteObservers.toArray(new MoteObservation[0]);
    for (MoteObservation o: moteObservers) {
      if (o.getMote() == mote) {
        o.dispose();
        activeMoteObservers.remove(o);
      }
    }

    numberMotesWasUpdated();
  }

  private void recalculateMoteHeight() {
    int h = EVENT_PIXEL_HEIGHT;
    if (showRadioRXTX) {
      h += EVENT_PIXEL_HEIGHT;
    }
    if (showRadioChannels) {
      h += EVENT_PIXEL_HEIGHT;
    }
    if (showRadioHW) {
      h += EVENT_PIXEL_HEIGHT;
    }
    if (showLEDs) {
      h += 3*LED_PIXEL_HEIGHT;
    }
    if (showLogOutputs) {
      h += EVENT_PIXEL_HEIGHT;
    }
    if (showWatchpoints) {
      h += EVENT_PIXEL_HEIGHT;
    }
    if (h != paintedMoteHeight) {
      paintedMoteHeight = h;
      timelineMoteRuler.repaint();
      timeline.repaint();
    }
  }

  public void closePlugin() {
    /* Remove repaint timer */
    repaintTimelineTimer.stop();

    if (moteHighlightObserver != null) {
      simulation.getGUI().deleteMoteHighlightObserver(moteHighlightObserver);
    }

    simulation.getEventCentral().removeMoteCountListener(newMotesListener);

    /* Remove active mote interface observers */
    for (MoteObservation o: activeMoteObservers) {
      o.dispose();
    }
    activeMoteObservers.clear();
  }

  public Collection<Element> getConfigXML() {
    ArrayList<Element> config = new ArrayList<Element>();
    Element element;

    /* Remember observed motes */
    Mote[] allMotes = simulation.getMotes();
    for (MoteEvents moteEvents: allMoteEvents) {
      element = new Element("mote");
      for (int i=0; i < allMotes.length; i++) {
        if (allMotes[i] == moteEvents.mote) {
          element.setText("" + i);
          config.add(element);
          break;
        }
      }
    }

    if (showRadioRXTX) {
      element = new Element("showRadioRXTX");
      config.add(element);
    }
    if (showRadioChannels) {
      element = new Element("showRadioChannels");
      config.add(element);
    }
    if (showRadioHW) {
      element = new Element("showRadioHW");
      config.add(element);
    }
    if (showLEDs) {
      element = new Element("showLEDs");
      config.add(element);
    }
    if (showLogOutputs) {
      element = new Element("showLogOutput");
      config.add(element);
    }
    if (showWatchpoints) {
      element = new Element("showWatchpoints");
      config.add(element);
    }

    if (executionDetails) {
      element = new Element("executionDetails");
      config.add(element);
    }
    if (radioChannels) {
      element = new Element("radioChannels");
      config.add(element);
    }

    element = new Element("split");
    element.addContent("" + splitPane.getDividerLocation());
    config.add(element);

    element = new Element("zoomfactor");
    element.addContent("" + currentPixelDivisor);
    config.add(element);

    return config;
  }

  public boolean setConfigXML(Collection<Element> configXML, boolean visAvailable) {
    showRadioRXTX = false;
    showRadioChannels = false;
    showRadioHW = false;
    showLEDs = false;
    showLogOutputs = false;
    showWatchpoints = false;

    executionDetails = false;
    radioChannels = false;

    /* Remove already registered motes */
    MoteEvents[] allMoteEventsArr = allMoteEvents.toArray(new MoteEvents[0]);
    for (MoteEvents moteEvents: allMoteEventsArr) {
      removeMote(moteEvents.mote);
    }

    for (Element element : configXML) {
      String name = element.getName();
      if ("mote".equals(name)) {
        int index = Integer.parseInt(element.getText());
        addMote(simulation.getMote(index));
      } else if ("showRadioRXTX".equals(name)) {
        showRadioRXTX = true;
      } else if ("showRadioChannels".equals(name)) {
        showRadioChannels = true;
      } else if ("showRadioHW".equals(name)) {
        showRadioHW = true;
      } else if ("showLEDs".equals(name)) {
        showLEDs = true;
      } else if ("showLogOutput".equals(name)) {
        showLogOutputs = true;
      } else if ("showWatchpoints".equals(name)) {
        showWatchpoints = true;
      } else if ("executionDetails".equals(name)) {
      	executionDetails = true;
      } else if ("radioChannels".equals(name)) {
      	radioChannels = true;
      } else if ("split".equals(name)) {
        splitPane.setDividerLocation(Integer.parseInt(element.getText()));
      } else if ("zoom".equals(name)) {
        currentPixelDivisor = ZOOM_LEVELS[Integer.parseInt(element.getText())-1];
        forceRepaintAndFocus(0, 0);
      } else if ("zoomfactor".equals(name)) {
        currentPixelDivisor = Double.parseDouble(element.getText());
        forceRepaintAndFocus(0, 0);
      }
    }

    /* XXX HACK: Update checkboxes according to config */
    for (Component c: eventCheckboxes.getComponents()) {
      if (c.getName() == "showRadioRXTX") {
        ((JCheckBox)c).setSelected(showRadioRXTX);
      } else if (c.getName() == "showRadioChannels") {
        ((JCheckBox)c).setSelected(showRadioChannels);
      } else if (c.getName() == "showRadioHW") {
        ((JCheckBox)c).setSelected(showRadioHW);
      } else if (c.getName() == "showLEDs") {
        ((JCheckBox)c).setSelected(showLEDs);
      } else if (c.getName() == "showLogOutput") {
        ((JCheckBox)c).setSelected(showLogOutputs);
      } else if (c.getName() == "showWatchpoints") {
        ((JCheckBox)c).setSelected(showWatchpoints);
      }
    }
    recalculateMoteHeight();

    return true;
  }


  private int mousePixelPositionX = -1;
  private int mousePixelPositionY = -1;
  private int mouseDownPixelPositionX = -1;
  class Timeline extends JComponent {
    private static final long serialVersionUID = 2206491823778169359L;
    public Timeline() {
      setLayout(null);
      setToolTipText(null);
      setBackground(COLOR_BACKGROUND);

      addMouseListener(mouseAdapter);
      addMouseMotionListener(mouseAdapter);

      /* Popup menu */
      final JPopupMenu popupMenu = new JPopupMenu();

      /*      popupMenu.add(new JMenuItem(addMoteAction));

      popupMenu.addSeparator();

      popupMenu.add(new JMenuItem(zoomInAction));
      popupMenu.add(new JMenuItem(zoomOutAction));
      popupMenu.add(new JMenuItem(zoomSliderAction));

      popupMenu.addSeparator();

      popupMenu.add(new JMenuItem(saveDataAction));
      popupMenu.add(new JMenuItem(statisticsAction));
      popupMenu.add(new JMenuItem(clearAction));

      popupMenu.addSeparator();
      */
      /*      JMenu focusMenu = new JMenu("Show in");*/
      popupMenu.add(new JMenuItem(showInAllAction));
      /*   focusMenu.addSeparator(); */
      popupMenu.add(new JMenuItem(logListenerAction));
      popupMenu.add(new JMenuItem(radioLoggerAction));
      /*      popupMenu.add(focusMenu);*/

      JMenu advancedMenu = new JMenu("Advanced");
      advancedMenu.add(new JCheckBoxMenuItem(executionDetailsAction) {
				private static final long serialVersionUID = 8314556794750277113L;
				public boolean isSelected() {
      		return executionDetails;
      	}
      });
      advancedMenu.add(new JCheckBoxMenuItem(radioChannelsAction) {
				private static final long serialVersionUID = 6830282466652559714L;
				public boolean isSelected() {
      		return radioChannels;
      	}
      });
      /*      popupMenu.add(advancedMenu);*/

      addMouseListener(new MouseAdapter() {
      	long lastClick = -1;
        public void mouseClicked(MouseEvent e) {
          if (e.isPopupTrigger()) {
            popupLocation = new Point(e.getX(), e.getY());
            popupMenu.show(Timeline.this, e.getX(), e.getY());
          }

          /* Focus on double-click */
          if (System.currentTimeMillis() - lastClick < 250) {
            popupLocation = e.getPoint();
            showInAllAction.actionPerformed(null);

            long time = (long) (popupLocation.x*currentPixelDivisor);
            Plugin[] plugins = simulation.getGUI().getStartedPlugins();
            for (Plugin p: plugins) {
            	if (!(p instanceof TimeLine)) {
            		continue;
            	}
            	if (p == TimeLine.this) {
            		continue;
            	}
              /* Select simulation time */
            	TimeLine plugin = (TimeLine) p;
              plugin.trySelectTime(time);
            }

          }
          lastClick = System.currentTimeMillis();
        }
        public void mousePressed(MouseEvent e) {
          if (e.isPopupTrigger()) {
            popupLocation = new Point(e.getX(), e.getY());
            popupMenu.show(Timeline.this, e.getX(), e.getY());
          }
        }
        public void mouseReleased(MouseEvent e) {
          if (e.isPopupTrigger()) {
            popupLocation = new Point(e.getX(), e.getY());
            popupMenu.show(Timeline.this, e.getX(), e.getY());
          }
        }
      });
    }

    private MouseAdapter mouseAdapter = new MouseAdapter() {
      private Popup popUpToolTip = null;
      private double zoomInitialPixelDivisor;
      private int zoomInitialMouseY;
      private long zoomCenterTime = -1;
      private double zoomCenter = -1;
      public void mouseDragged(MouseEvent e) {
        super.mouseDragged(e);
        if (e.isControlDown()) {
          /* Zoom with mouse */
          if (zoomCenterTime < 0) {
            return;
          }

          double factor = 0.01*(e.getY() - zoomInitialMouseY);
          factor = Math.exp(factor);

          currentPixelDivisor = zoomInitialPixelDivisor * factor;
          if (currentPixelDivisor < ZOOM_LEVELS[0]) {
            currentPixelDivisor = ZOOM_LEVELS[0];
          }
          if (currentPixelDivisor > ZOOM_LEVELS[ZOOM_LEVELS.length-1]) {
            currentPixelDivisor = ZOOM_LEVELS[ZOOM_LEVELS.length-1];
          }
          forceRepaintAndFocus(zoomCenterTime, zoomCenter);
          return;
        }
        if (e.isAltDown()) {
          /* Pan with mouse */
          if (zoomCenterTime < 0) {
            return;
          }

          zoomCenter = (double) (e.getX() - timeline.getVisibleRect().x) / timeline.getVisibleRect().width;
          forceRepaintAndFocus(zoomCenterTime, zoomCenter);
          return;
        }

        if (mousePixelPositionX >= 0) {
          mousePixelPositionX = e.getX();
          mousePixelPositionY = e.getY();
          repaint();
        }
      }
      public void mousePressed(MouseEvent e) {
        if (e.isControlDown()) {
          /* Zoom with mouse */
          zoomInitialMouseY = e.getY();
          zoomInitialPixelDivisor = currentPixelDivisor;
          zoomCenterTime = (long) (e.getX()*currentPixelDivisor);
          zoomCenter = (double) (e.getX() - timeline.getVisibleRect().x) / timeline.getVisibleRect().width;
          return;
        }
        if (e.isAltDown()) {
          /* Pan with mouse */
          zoomCenterTime = (long) (e.getX()*currentPixelDivisor);
          return;
        }

        if (popUpToolTip != null) {
          popUpToolTip.hide();
          popUpToolTip = null;
        }
        if (e.getPoint().getY() < FIRST_MOTE_PIXEL_OFFSET) {
          mousePixelPositionX = e.getX();
          mouseDownPixelPositionX = e.getX();
          mousePixelPositionY = e.getY();
          repaint();
        } else {
          /* Trigger tooltip */
          JToolTip t = timeline.createToolTip();
          t.setTipText(Timeline.this.getMouseToolTipText(e));
          if (t.getTipText() == null || t.getTipText().equals("")) {
            return;
          }
          t.validate();

          /* Check tooltip width */
          Rectangle screenBounds = timeline.getGraphicsConfiguration().getBounds();
          int x;
          {
            int tooltip = e.getLocationOnScreen().x + t.getPreferredSize().width;
            int screen = screenBounds.x + screenBounds.width;
            if (tooltip > screen) {
              x = e.getLocationOnScreen().x - (tooltip-screen);
            } else {
              x = e.getLocationOnScreen().x;
            }
          }

          /* Check tooltip height */
          int y;
          {
            int tooltip = e.getLocationOnScreen().y + t.getPreferredSize().height;
            int screen = screenBounds.y + screenBounds.height;
            if (tooltip > screen) {
              y = e.getLocationOnScreen().y - (tooltip-screen);
            } else {
              y = e.getLocationOnScreen().y;
            }
          }

          popUpToolTip = PopupFactory.getSharedInstance().getPopup(null, t, x, y);
          popUpToolTip.show();
        }
      }
      public void mouseReleased(MouseEvent e) {
        zoomCenterTime = -1;
        if (popUpToolTip != null) {
          popUpToolTip.hide();
          popUpToolTip = null;
        }
        super.mouseReleased(e);
        mousePixelPositionX = -1;
        repaint();
      }
    };

    private final Color SEPARATOR_COLOR = new Color(220, 220, 220);
    public void paintComponent(Graphics g) {
      Rectangle bounds = g.getClipBounds();
      /*logger.info("Clip bounds: " + bounds);*/

      if (needZoomOut) {
        /* Need zoom out */
        g.setColor(Color.RED);
        g.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);

        Rectangle vis = timeline.getVisibleRect();
        g.setColor(Color.WHITE);
        String msg = "Zoom out";
        FontMetrics fm = g.getFontMetrics();
        int msgWidth = fm.stringWidth(msg);
        int msgHeight = fm.getHeight();
        g.drawString(msg,
            vis.x + vis.width/2 - msgWidth/2,
            vis.y + vis.height/2 + msgHeight/2);
        return;
      }

      long intervalStart = (long)(bounds.x*currentPixelDivisor);
      long intervalEnd = (long) (intervalStart + bounds.width*currentPixelDivisor);

      if (intervalEnd > simulation.getSimulationTime()) {
        intervalEnd = simulation.getSimulationTime();
      }

      /*logger.info("Painting interval: " + intervalStart + " -> " + intervalEnd);*/
      if (bounds.x > Integer.MAX_VALUE - 1000) {
        /* TODO Strange bounds */
        return;
      }

      g.setColor(COLOR_BACKGROUND);
      g.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);

      drawTimeRule(g, intervalStart, intervalEnd);

      /* Paint mote events */
      int lineHeightOffset = FIRST_MOTE_PIXEL_OFFSET;
      boolean dark = true;
      for (int mIndex = 0; mIndex < allMoteEvents.size(); mIndex++) {

        /* Mote separators */
        if (dark) {
          g.setColor(SEPARATOR_COLOR);
          g.fillRect(
              0, lineHeightOffset-2,
              getWidth(), paintedMoteHeight
          );
        }
        dark = !dark;

        if (showRadioRXTX) {
          MoteEvent firstEvent = getFirstIntervalEvent(allMoteEvents.get(mIndex).radioRXTXEvents, intervalStart);
          if (firstEvent != null) {
            firstEvent.paintInterval(g, lineHeightOffset, intervalEnd);
          }
          lineHeightOffset += EVENT_PIXEL_HEIGHT;
        }
        if (showRadioChannels) {
          MoteEvent firstEvent = getFirstIntervalEvent(allMoteEvents.get(mIndex).radioChannelEvents, intervalStart);
          if (firstEvent != null) {
            firstEvent.paintInterval(g, lineHeightOffset, intervalEnd);
          }
          lineHeightOffset += EVENT_PIXEL_HEIGHT;
        }
        if (showRadioHW) {
          MoteEvent firstEvent = getFirstIntervalEvent(allMoteEvents.get(mIndex).radioHWEvents, intervalStart);
          if (firstEvent != null) {
            firstEvent.paintInterval(g, lineHeightOffset, intervalEnd);
          }
          lineHeightOffset += EVENT_PIXEL_HEIGHT;
        }
        if (showLEDs) {
          MoteEvent firstEvent = getFirstIntervalEvent(allMoteEvents.get(mIndex).ledEvents, intervalStart);
          if (firstEvent != null) {
            firstEvent.paintInterval(g, lineHeightOffset, intervalEnd);
          }
          lineHeightOffset += 3*LED_PIXEL_HEIGHT;
        }
        if (showLogOutputs) {
          MoteEvent firstEvent = getFirstIntervalEvent(allMoteEvents.get(mIndex).logEvents, intervalStart);
          if (firstEvent != null) {
            firstEvent.paintInterval(g, lineHeightOffset, intervalEnd);
          }
          lineHeightOffset += EVENT_PIXEL_HEIGHT;
        }
        if (showWatchpoints) {
          MoteEvent firstEvent = getFirstIntervalEvent(allMoteEvents.get(mIndex).watchpointEvents, intervalStart);
          if (firstEvent != null) {
            firstEvent.paintInterval(g, lineHeightOffset, intervalEnd);
          }
          lineHeightOffset += EVENT_PIXEL_HEIGHT;
        }

        lineHeightOffset += EVENT_PIXEL_HEIGHT;
      }

      /* Draw vertical time marker (if mouse is dragged) */
      drawMouseTime(g, intervalStart, intervalEnd);
    }

    private <T extends MoteEvent> T getFirstIntervalEvent(ArrayList<T> events, long time) {
      /* TODO IMPLEMENT ME: Binary search */
      int nrEvents = events.size();
      if (nrEvents == 0) {
        return null;
      }
      if (nrEvents == 1) {
        events.get(0);
      }

      int ev = 0;
      while (ev < nrEvents && events.get(ev).time < time) {
        ev++;
      }
      ev--;
      if (ev < 0) {
        ev = 0;
      }

      if (ev >= events.size()) {
        return events.get(events.size()-1);
      }
      return events.get(ev);
    }

    private void drawTimeRule(Graphics g, long start, long end) {
      long time;

      /* Paint 10ms and 100 ms markers */
      g.setColor(Color.GRAY);

      time = start - (start % (100*Simulation.MILLISECOND));
      while (time <= end) {
        if (time % (100*Simulation.MILLISECOND) == 0) {
          g.drawLine(
              (int) (time/currentPixelDivisor), 0,
              (int) (time/currentPixelDivisor), TIME_MARKER_PIXEL_HEIGHT);
        } else {
          g.drawLine(
              (int) (time/currentPixelDivisor), 0,
              (int) (time/currentPixelDivisor), TIME_MARKER_PIXEL_HEIGHT/2);
        }
        time += (10*Simulation.MILLISECOND);
      }
    }

    private void drawMouseTime(Graphics g, long start, long end) {
      if (mousePixelPositionX >= 0) {
        long time = (long) (mousePixelPositionX*currentPixelDivisor);
        long diff = (long) (Math.abs(mouseDownPixelPositionX-mousePixelPositionX)*currentPixelDivisor);
        String str =
        	"Time (ms): " + (double)time/Simulation.MILLISECOND +
        	" (" + (double)diff/Simulation.MILLISECOND + ")";

        int h = g.getFontMetrics().getHeight();
        int w = g.getFontMetrics().stringWidth(str) + 6;
        int y= mousePixelPositionY<getHeight()/2?0:getHeight()-h;
        int delta = mousePixelPositionX + w > end/currentPixelDivisor?w:0; /* Don't write outside visible area */

        /* Line */
        g.setColor(Color.GRAY);
        g.drawLine(
            mousePixelPositionX, 0,
            mousePixelPositionX, getHeight());

        /* Text box */
        g.setColor(Color.DARK_GRAY);
        g.fillRect(
            mousePixelPositionX-delta, y,
            w, h);
        g.setColor(Color.BLACK);
        g.drawRect(
            mousePixelPositionX-delta, y,
            w, h);
        g.setColor(Color.WHITE);
        g.drawString(str,
            mousePixelPositionX+3-delta,
            y+h-1);
      }
    }

    public String getMouseToolTipText(MouseEvent event) {
      if (event.getPoint().y <= TIME_MARKER_PIXEL_HEIGHT) {
        return "<html>Click to display time marker</html>";
      }
      if (event.getPoint().y <= FIRST_MOTE_PIXEL_OFFSET) {
        return null;
      }

      /* Mote */
      int mote = (event.getPoint().y-FIRST_MOTE_PIXEL_OFFSET)/paintedMoteHeight;
      if (mote < 0 || mote >= allMoteEvents.size()) {
        return null;
      }
      String tooltip = "<html>Mote: " + allMoteEvents.get(mote).mote + "<br>";

      /* Time */
      long time = (long) (event.getPoint().x*currentPixelDivisor);
      tooltip += "Time (ms): " + (double)time/Simulation.MILLISECOND + "<br>";

      /* Event */
      ArrayList<? extends MoteEvent> events = null;
      int evMatched = 0;
      int evMouse = ((event.getPoint().y-FIRST_MOTE_PIXEL_OFFSET) % paintedMoteHeight) / EVENT_PIXEL_HEIGHT;
      if (showRadioRXTX) {
        if (evMatched == evMouse) {
          events = allMoteEvents.get(mote).radioRXTXEvents;
        }
        evMatched++;
      }
      if (showRadioChannels) {
        if (evMatched == evMouse) {
          events = allMoteEvents.get(mote).radioChannelEvents;
        }
        evMatched++;
      }
      if (showRadioHW) {
        if (evMatched == evMouse) {
          events = allMoteEvents.get(mote).radioHWEvents;
        }
        evMatched++;
      }
      if (showLEDs) {
        if (evMatched == evMouse) {
          events = allMoteEvents.get(mote).ledEvents;
        }
        evMatched++;
      }
      if (showLogOutputs) {
        if (evMatched == evMouse) {
          events = allMoteEvents.get(mote).logEvents;
        }
        evMatched++;
      }
      if (showWatchpoints) {
        if (evMatched == evMouse) {
          events = allMoteEvents.get(mote).watchpointEvents;
        }
        evMatched++;
      }
      if (events != null) {
        MoteEvent ev = getFirstIntervalEvent(events, time);
        if (ev != null && time >= ev.time) {
          tooltip += ev + "<br>";

        	if (ev.details != null) {
        		tooltip += "Details:<br>" + ev.details;
        	}
        }
      }

      tooltip += "</html>";
      return tooltip;
    }
  }

  class MoteRuler extends JPanel {
    private static final long serialVersionUID = -5555627354526272220L;

    public MoteRuler() {
      setPreferredSize(new Dimension(35, 1));
      setToolTipText(null);
      setBackground(COLOR_BACKGROUND);

      final JPopupMenu popupMenu = new JPopupMenu();
      final JMenuItem topItem = new JMenuItem(topMoteAction);
      topItem.setText("Move to top");
      popupMenu.add(topItem);
      final JMenuItem sortItem = new JMenuItem(sortMoteAction);
      sortItem.setText("Sort by distance");
      popupMenu.add(sortItem);
      final JMenuItem removeItem = new JMenuItem(removeMoteAction);
      removeItem.setText("Remove from timeline");
      popupMenu.add(removeItem);
      final JMenuItem keepMoteOnlyItem = new JMenuItem(removeAllOtherMotesAction);
      keepMoteOnlyItem.setText("Remove all motes from timeline but");
      popupMenu.add(keepMoteOnlyItem);

      addMouseListener(new MouseAdapter() {
        public void mouseClicked(MouseEvent e) {
          Mote m = getMote(e.getPoint());
          if (m == null) {
            return;
          }
          simulation.getGUI().signalMoteHighlight(m);

          sortItem.setText("Sort by distance: " + m);
          sortItem.putClientProperty("mote", m);
          topItem.setText("Move to top: " + m);
          topItem.putClientProperty("mote", m);
          removeItem.setText("Remove from timeline: " + m);
          removeItem.putClientProperty("mote", m);
          keepMoteOnlyItem.setText("Remove all motes from timeline but: " + m);
          keepMoteOnlyItem.putClientProperty("mote", m);
          popupMenu.show(MoteRuler.this, e.getX(), e.getY());
        }
      });
    }

    private Mote getMote(Point p) {
      if (p.y < FIRST_MOTE_PIXEL_OFFSET) {
        return null;
      }
      int m = (p.y-FIRST_MOTE_PIXEL_OFFSET)/paintedMoteHeight;
      if (m < allMoteEvents.size()) {
        return allMoteEvents.get(m).mote;
      }
      return null;
    }

    protected void paintComponent(Graphics g) {
      g.setColor(COLOR_BACKGROUND);
      g.fillRect(0, 0, getWidth(), getHeight());
      g.setColor(Color.BLACK);

      g.setFont(new Font("SansSerif", Font.PLAIN, paintedMoteHeight));
      int y = FIRST_MOTE_PIXEL_OFFSET-EVENT_PIXEL_HEIGHT/2+paintedMoteHeight;
      for (MoteEvents moteLog: allMoteEvents) {
        String str = "" + moteLog.mote.getID();
        int w = g.getFontMetrics().stringWidth(str) + 1;

        if (highlightedMotes.contains(moteLog.mote)) {
        	g.setColor(HIGHLIGHT_COLOR);
          g.fillRect(0, y-paintedMoteHeight, getWidth()-1, paintedMoteHeight);
          g.setColor(Color.BLACK);
        }
        g.drawString(str, getWidth() - w, y);
        y += paintedMoteHeight;
      }
    }

    public String getToolTipText(MouseEvent event) {
      Point p = event.getPoint();
      Mote m = getMote(p);
      if (m == null)
        return null;

      return "<html>" + m + "<br>Click mote for options</html>";
    }
  }

  /* Event classes */
  abstract class MoteEvent {
    MoteEvent prev = null;
    MoteEvent next = null;
    String details = null;
    long time;
    public MoteEvent(long time) {
      this.time = time;
    }

    /**
     * Used by the default paint method to color events.
     * The event is not painted if the returned color is null.
     *
     * @see #paintInterval(Graphics, int, long)
     * @return Event color or null
     */
    public abstract Color getEventColor();

    /* Default paint method */
    public void paintInterval(Graphics g, int lineHeightOffset, long end) {
      MoteEvent ev = this;
      while (ev != null && ev.time < end) {
        int w; /* Pixel width */

        /* Calculate event width */
        if (ev.next != null) {
          w = (int) ((ev.next.time - ev.time)/currentPixelDivisor);
        } else {
          w = (int) ((end - ev.time)/currentPixelDivisor); /* No more events */
        }

        /* Handle zero pixel width events */
        if (w == 0) {
          if (PAINT_ZERO_WIDTH_EVENTS) {
            w = 1;
          } else {
            ev = ev.next;
            continue;
          }
        }

        Color color = ev.getEventColor();
        if (color == null) {
          /* Skip painting event */
          ev = ev.next;
          continue;
        }
        g.setColor(color);

        g.fillRect(
            (int)(ev.time/currentPixelDivisor), lineHeightOffset,
            w, EVENT_PIXEL_HEIGHT
        );

        ev = ev.next;
      }
    }
  }
  class NoHistoryEvent extends MoteEvent {
    public NoHistoryEvent(long time) {
      super(time);
    }
    public Color getEventColor() {
      return Color.CYAN;
    }
    public String toString() {
      return "No events has been captured yet";
    }
  }
  public enum RXTXRadioEvent {
    IDLE, RECEIVING, TRANSMITTING, INTERFERED
  }
  class RadioRXTXEvent extends MoteEvent {
    RXTXRadioEvent state = null;
    public RadioRXTXEvent(long time, RXTXRadioEvent ev) {
      super(time);
      this.state = ev;
    }
    public RadioRXTXEvent(long time, RXTXRadioEvent ev, String details) {
      this(time, ev);
      this.details = details;
    }
    public Color getEventColor() {
      if (state == RXTXRadioEvent.IDLE) {
        return null;
      } else if (state == RXTXRadioEvent.TRANSMITTING) {
        return Color.BLUE;
      } else if (state == RXTXRadioEvent.RECEIVING) {
        return Color.GREEN;
      } else if (state == RXTXRadioEvent.INTERFERED) {
        return Color.RED;
      } else {
        logger.fatal("Unknown RXTX event");
        return null;
      }
    }
    public String toString() {
      if (state == RXTXRadioEvent.IDLE) {
        return "Radio idle from " + time + "<br>";
      } else if (state == RXTXRadioEvent.TRANSMITTING) {
      	return "Radio transmitting from " + time + "<br>";
      } else if (state == RXTXRadioEvent.RECEIVING) {
        return "Radio receiving from " + time + "<br>";
      } else if (state == RXTXRadioEvent.INTERFERED) {
        return "Radio interfered from " + time + "<br>";
      } else {
        return "Unknown event<br>";
      }
    }
  }
  class RadioChannelEvent extends MoteEvent {
    public RadioChannelEvent(long time) {
      super(time);
    }
    public Color getEventColor() {
      return Color.GRAY; /* TODO Implement me */
    }
  }

  /* TODO Which colors? */
  private final static Color[] CHANNEL_COLORS = new Color[] {
    Color.decode("0x008080"), Color.decode("0x808080"), Color.decode("0xC00000"),
    Color.decode("0x000020"), Color.decode("0x202000"), Color.decode("0x200020"),
    Color.decode("0x002020"), Color.decode("0x202020"), Color.decode("0x006060"),
    Color.decode("0x606060"), Color.decode("0xA00000"), Color.decode("0x00A000"),
    Color.decode("0x0000A0"), Color.decode("0x400040"), Color.decode("0x004040"),
    Color.decode("0x404040"), Color.decode("0x200000"), Color.decode("0x002000"),
    Color.decode("0xA0A000"), Color.decode("0xA000A0"), Color.decode("0x00A0A0"),
    Color.decode("0xA0A0A0"), Color.decode("0xE00000"), Color.decode("0x600000"),
    Color.decode("0x000040"), Color.decode("0x404000"), Color.decode("0xFF0000"),
    Color.decode("0x00FF00"), Color.decode("0x0000FF"), Color.decode("0xFFFF00"),
    Color.decode("0xFF00FF"), Color.decode("0x808000"), Color.decode("0x800080"),
  };
  class RadioHWEvent extends MoteEvent {
    boolean on;
    int channel = -1;
    public RadioHWEvent(long time, boolean on) {
      super(time);
      this.on = on;
    }
    public RadioHWEvent(long time, boolean on, int channel) {
    	this(time, on);
    	this.channel = channel;
    }
    public Color getEventColor() {
    	if (on && radioChannels && channel >= 0 && channel < CHANNEL_COLORS.length) {
    		return CHANNEL_COLORS[channel];
    	}
      return on?Color.GRAY:null;
    }
    public String toString() {
      String str = "Radio HW was turned " + (on?"on":"off") + " at time " + time + "<br>";
      if (channel > 0) {
      	str += "Radio channel: " + channel;
      }
      return str;
    }
  }
  class LEDEvent extends MoteEvent {
    boolean red;
    boolean green;
    boolean blue;
    Color color;
    public LEDEvent(long time, boolean red, boolean green, boolean blue) {
      super(time);
      this.red = red;
      this.green = green;
      this.blue = blue;
      this.color = new Color(red?255:0, green?255:0, blue?255:0);
    }
    public Color getEventColor() {
      if (!red && !green && !blue) {
        return null;
      } else if (red && green && blue) {
        return Color.LIGHT_GRAY;
      } else {
        return color;
      }
    }
    /* LEDs are painted in three lines */
    public void paintInterval(Graphics g, int lineHeightOffset, long end) {
      MoteEvent ev = this;
      while (ev != null && ev.time < end) {
        int w; /* Pixel width */

        /* Calculate event width */
        if (ev.next != null) {
          w = (int) ((ev.next.time - ev.time)/currentPixelDivisor);
        } else {
          w = (int) ((end - ev.time)/currentPixelDivisor); /* No more events */
        }

        /* Handle zero pixel width events */
        if (w == 0) {
          if (PAINT_ZERO_WIDTH_EVENTS) {
            w = 1;
          } else {
            ev = ev.next;
            continue;
          }
        }

        Color color = ev.getEventColor();
        if (color == null) {
          /* Skip painting event */
          ev = ev.next;
          continue;
        }
        if (color.getRed() > 0) {
          g.setColor(new Color(color.getRed(), 0, 0));
          g.fillRect(
              (int)(ev.time/currentPixelDivisor), lineHeightOffset,
              w, LED_PIXEL_HEIGHT
          );
        }
        if (color.getGreen() > 0) {
          g.setColor(new Color(0, color.getGreen(), 0));
          g.fillRect(
              (int)(ev.time/currentPixelDivisor), lineHeightOffset+LED_PIXEL_HEIGHT,
              w, LED_PIXEL_HEIGHT
          );
        }
        if (color.getBlue() > 0) {
          g.setColor(new Color(0, 0, color.getBlue()));
          g.fillRect(
              (int)(ev.time/currentPixelDivisor), lineHeightOffset+2*LED_PIXEL_HEIGHT,
              w, LED_PIXEL_HEIGHT
          );
        }
        ev = ev.next;
      }
    }
    public String toString() {
      return
      "LED state:<br>" +
      "Red = " + (red?"ON":"OFF") + "<br>" +
      "Green = " + (green?"ON":"OFF") + "<br>" +
      "Blue = " + (blue?"ON":"OFF") + "<br>";
    }
  }
  class LogEvent extends MoteEvent {
    public LogEvent(long time) {
      super(time);
    }
    public Color getEventColor() {
      return Color.GRAY; /* TODO Implement me */
    }
  }
  class WatchpointEvent extends MoteEvent {
    Watchpoint watchpoint;
    public WatchpointEvent(long time, Watchpoint watchpoint) {
      super(time);
      this.watchpoint = watchpoint;
    }
    public Color getEventColor() {
      Color c = watchpoint.getColor();
      if (c == null) {
        return Color.BLACK;
      }
      return c;
    }
    public String toString() {
      String desc = watchpoint.getDescription();
      desc = desc.replace("\n", "<br>");
      return
      "Watchpoint triggered at time (ms): " +  time/Simulation.MILLISECOND + ".<br>"
      + desc + "<br>";
    }

    /* Default paint method */
    public void paintInterval(Graphics g, int lineHeightOffset, long end) {
      MoteEvent ev = this;
      while (ev != null && ev.time < end) {
        int w = 2; /* Watchpoints are always two pixels wide */

        Color color = ev.getEventColor();
        if (color == null) {
          /* Skip painting event */
          ev = ev.next;
          continue;
        }
        g.setColor(color);

        g.fillRect(
            (int)(ev.time/currentPixelDivisor), lineHeightOffset,
            w, EVENT_PIXEL_HEIGHT
        );

        ev = ev.next;
      }
    }
  }
  class MoteEvents {
    Mote mote;
    ArrayList<MoteEvent> radioRXTXEvents;
    ArrayList<MoteEvent> radioChannelEvents;
    ArrayList<MoteEvent> radioHWEvents;
    ArrayList<MoteEvent> ledEvents;
    ArrayList<MoteEvent> logEvents;
    ArrayList<MoteEvent> watchpointEvents;

    private MoteEvent lastRadioRXTXEvent = null;
    private MoteEvent lastRadioChannelEvent = null;
    private MoteEvent lastRadioHWEvent = null;
    private MoteEvent lastLEDEvent = null;
    private MoteEvent lastLogEvent = null;
    private MoteEvent lastWatchpointEvent = null;

    public MoteEvents(Mote mote) {
      this.mote = mote;
      this.radioRXTXEvents = new ArrayList<MoteEvent>();
      this.radioChannelEvents = new ArrayList<MoteEvent>();
      this.radioHWEvents = new ArrayList<MoteEvent>();
      this.ledEvents = new ArrayList<MoteEvent>();
      this.logEvents = new ArrayList<MoteEvent>();
      this.watchpointEvents = new ArrayList<MoteEvent>();

      if (mote.getSimulation().getSimulationTime() > 0) {
        /* Create no history events */
        lastRadioRXTXEvent = new NoHistoryEvent(0);
        lastRadioChannelEvent = new NoHistoryEvent(0);
        lastRadioHWEvent = new NoHistoryEvent(0);
        lastLEDEvent = new NoHistoryEvent(0);
        lastLogEvent = new NoHistoryEvent(0);
        lastWatchpointEvent = new NoHistoryEvent(0);
        radioRXTXEvents.add(lastRadioRXTXEvent);
        radioChannelEvents.add(lastRadioChannelEvent);
        radioHWEvents.add(lastRadioHWEvent);
        ledEvents.add(lastLEDEvent);
        logEvents.add(lastLogEvent);
        watchpointEvents.add(lastWatchpointEvent);
      }
    }

    protected void clear() {
      this.radioRXTXEvents.clear();
      this.radioChannelEvents.clear();
      this.radioHWEvents.clear();
      this.ledEvents.clear();
      this.logEvents.clear();
      this.watchpointEvents.clear();

      if (mote.getSimulation().getSimulationTime() > 0) {
        /* Create no history events */
        lastRadioRXTXEvent = new NoHistoryEvent(0);
        lastRadioChannelEvent = new NoHistoryEvent(0);
        lastRadioHWEvent = new NoHistoryEvent(0);
        lastLEDEvent = new NoHistoryEvent(0);
        lastLogEvent = new NoHistoryEvent(0);
        lastWatchpointEvent = new NoHistoryEvent(0);
        radioRXTXEvents.add(lastRadioRXTXEvent);
        radioChannelEvents.add(lastRadioChannelEvent);
        radioHWEvents.add(lastRadioHWEvent);
        ledEvents.add(lastLEDEvent);
        logEvents.add(lastLogEvent);
        watchpointEvents.add(lastWatchpointEvent);
      }
    }

    public void addRadioRXTX(RadioRXTXEvent ev) {
      /* Link with previous events */
      if (lastRadioRXTXEvent != null) {
        ev.prev = lastRadioRXTXEvent;
        lastRadioRXTXEvent.next = ev;
      }
      lastRadioRXTXEvent = ev;

      radioRXTXEvents.add(ev);
    }
    public void addRadioChannel(RadioChannelEvent ev) {
      /* Link with previous events */
      if (lastRadioChannelEvent != null) {
        ev.prev = lastRadioChannelEvent;
        lastRadioChannelEvent.next = ev;
      }
      lastRadioChannelEvent = ev;

      /* TODO XXX Requires MSPSim changes */
      radioChannelEvents.add(ev);
    }
    public void addRadioHW(RadioHWEvent ev) {
      /* Link with previous events */
      if (lastRadioHWEvent != null) {
        ev.prev = lastRadioHWEvent;
        lastRadioHWEvent.next = ev;
      }
      lastRadioHWEvent = ev;

      radioHWEvents.add(ev);
    }
    public void addLED(LEDEvent ev) {
      /* Link with previous events */
      if (lastLEDEvent != null) {
        ev.prev = lastLEDEvent;
        lastLEDEvent.next = ev;
      }
      lastLEDEvent = ev;

      ledEvents.add(ev);
    }
    public void addLog(LogEvent ev) {
      /* Link with previous events */
      if (lastLogEvent != null) {
        ev.prev = lastLogEvent;
        lastLogEvent.next = ev;
      }
      lastLogEvent = ev;

      logEvents.add(ev);
    }
    public void addWatchpoint(WatchpointEvent ev) {
      /* Link with previous events */
      if (lastWatchpointEvent != null) {
        ev.prev = lastWatchpointEvent;
        lastWatchpointEvent.next = ev;
      }
      lastWatchpointEvent = ev;

      watchpointEvents.add(ev);
    }
  }

  private long lastRepaintSimulationTime = -1;
  private Timer repaintTimelineTimer = new Timer(TIMELINE_UPDATE_INTERVAL, new ActionListener() {
    public void actionPerformed(ActionEvent e) {
      /* Only set new size if simulation time has changed */
      long now = simulation.getSimulationTime();
      if (now == lastRepaintSimulationTime) {
        return;
      }
      lastRepaintSimulationTime = now;

      /* Update timeline size */
      int newWidth;
      if (now/currentPixelDivisor > Integer.MAX_VALUE) {
        /* Need zoom out */
        newWidth = 1;
        needZoomOut = true;
      } else {
        newWidth = (int) (now/currentPixelDivisor);
        needZoomOut = false;
      }

      Rectangle visibleRectangle = timeline.getVisibleRect();
      boolean isTracking = visibleRectangle.x + visibleRectangle.width >= timeline.getWidth();

      int newHeight = (FIRST_MOTE_PIXEL_OFFSET + paintedMoteHeight * allMoteEvents.size());
      timeline.setPreferredSize(new Dimension(
          newWidth,
          newHeight
      ));
      timelineMoteRuler.setPreferredSize(new Dimension(
          35,
          newHeight
      ));
      timeline.revalidate();
      timeline.repaint();

      /* Update visible rectangle */
      if (isTracking) {
        Rectangle r = new Rectangle(
            newWidth-1, visibleRectangle.y,
            1, 1);
        timeline.scrollRectToVisible(r);
      }
    }
  });

  public String getQuickHelp() {
    return
        "<b>Timeline</b>" +
        "<p>The timeline shows simulation events over time. " +
        "The timeline can be used to inspect activities of individual nodes as well as interactions between nodes." +
        "<p>For each mote, simulation events are shown on a colored line. Different colors correspond to different events. For more information about a particular event, hover the mouse above it." +
        "<p>The <i>Events</i> menu control what event types are shown in the timeline. " +
        "Currently, four event types are supported (see below). " +
        "<p>All motes are by default shown in the timeline. Motes can be removed from the timeline by right-clicking the node ID on the left." +
        "<p>To display a vertical time marker on the timeline, press and hold the mouse on the time ruler (top)." +
        "<p>For more options for a given event, right-click the mouse for a popup menu." +
        "<p><b>Radio traffic</b>" +
        "<br>Shows radio traffic events. Transmissions are painted blue, receptions are green, and interfered radios are red." +
        "<p><b>Radio state</b>" +
        "<br>Shows whether the mote radio is on or off. When gray, the radio is on." +
        "<p><b>LEDs</b>" +
        "<br>Shows LED state: red, green, and blue. (Assumes all mote types have exactly three LEDs.)" +
        "<p><b>Watchpoints</b>" +
        "<br>Shows triggered watchpoints, currently only supported by MSPSim-based motes. To add watchpoints, use the Msp Code Watcher plugin.";
  }
}
