]> gitweb.fperrin.net Git - GpsPrune.git/commitdiff
Version 1, September 2006
authoractivityworkshop <mail@activityworkshop.net>
Sat, 14 Feb 2015 13:29:47 +0000 (14:29 +0100)
committeractivityworkshop <mail@activityworkshop.net>
Sat, 14 Feb 2015 13:29:47 +0000 (14:29 +0100)
57 files changed:
tim/prune/App.java [new file with mode: 0644]
tim/prune/DataSubscriber.java [new file with mode: 0644]
tim/prune/GpsPruner.java [new file with mode: 0644]
tim/prune/I18nManager.java [new file with mode: 0644]
tim/prune/UpdateMessageBroker.java [new file with mode: 0644]
tim/prune/data/Altitude.java [new file with mode: 0644]
tim/prune/data/AltitudeRange.java [new file with mode: 0644]
tim/prune/data/Coordinate.java [new file with mode: 0644]
tim/prune/data/DataPoint.java [new file with mode: 0644]
tim/prune/data/Distance.java [new file with mode: 0644]
tim/prune/data/DoubleRange.java [new file with mode: 0644]
tim/prune/data/Field.java [new file with mode: 0644]
tim/prune/data/FieldList.java [new file with mode: 0644]
tim/prune/data/FieldType.java [new file with mode: 0644]
tim/prune/data/FileInfo.java [new file with mode: 0644]
tim/prune/data/IntegerRange.java [new file with mode: 0644]
tim/prune/data/Latitude.java [new file with mode: 0644]
tim/prune/data/Longitude.java [new file with mode: 0644]
tim/prune/data/Selection.java [new file with mode: 0644]
tim/prune/data/Timestamp.java [new file with mode: 0644]
tim/prune/data/Track.java [new file with mode: 0644]
tim/prune/data/TrackInfo.java [new file with mode: 0644]
tim/prune/gui/AboutScreen.java [new file with mode: 0644]
tim/prune/gui/DetailsDisplay.java [new file with mode: 0644]
tim/prune/gui/GenericChart.java [new file with mode: 0644]
tim/prune/gui/GenericDisplay.java [new file with mode: 0644]
tim/prune/gui/MapChart.java [new file with mode: 0644]
tim/prune/gui/MenuManager.java [new file with mode: 0644]
tim/prune/gui/ProfileChart.java [new file with mode: 0644]
tim/prune/gui/UndoManager.java [new file with mode: 0644]
tim/prune/lang/prune-texts.properties [new file with mode: 0644]
tim/prune/lang/prune-texts_de.properties [new file with mode: 0644]
tim/prune/lang/prune-texts_de_CH.properties [new file with mode: 0644]
tim/prune/license.txt [new file with mode: 0644]
tim/prune/load/DelimiterInfo.java [new file with mode: 0644]
tim/prune/load/FieldSelectionTableModel.java [new file with mode: 0644]
tim/prune/load/FileCacher.java [new file with mode: 0644]
tim/prune/load/FileExtractTableModel.java [new file with mode: 0644]
tim/prune/load/FileLoader.java [new file with mode: 0644]
tim/prune/load/FileSplitter.java [new file with mode: 0644]
tim/prune/load/OneCharDocument.java [new file with mode: 0644]
tim/prune/readme.txt [new file with mode: 0644]
tim/prune/save/FieldInfo.java [new file with mode: 0644]
tim/prune/save/FieldSelectionTableModel.java [new file with mode: 0644]
tim/prune/save/FileSaver.java [new file with mode: 0644]
tim/prune/save/KmlExporter.java [new file with mode: 0644]
tim/prune/save/UpDownToggler.java [new file with mode: 0644]
tim/prune/undo/UndoCompress.java [new file with mode: 0644]
tim/prune/undo/UndoDeleteDuplicates.java [new file with mode: 0644]
tim/prune/undo/UndoDeletePoint.java [new file with mode: 0644]
tim/prune/undo/UndoDeleteRange.java [new file with mode: 0644]
tim/prune/undo/UndoException.java [new file with mode: 0644]
tim/prune/undo/UndoInsert.java [new file with mode: 0644]
tim/prune/undo/UndoLoad.java [new file with mode: 0644]
tim/prune/undo/UndoOperation.java [new file with mode: 0644]
tim/prune/undo/UndoRearrangeWaypoints.java [new file with mode: 0644]
tim/prune/undo/UndoReverseSection.java [new file with mode: 0644]

diff --git a/tim/prune/App.java b/tim/prune/App.java
new file mode 100644 (file)
index 0000000..a1e6a8b
--- /dev/null
@@ -0,0 +1,531 @@
+package tim.prune;
+
+import java.util.EmptyStackException;
+import java.util.Stack;
+
+import javax.swing.JFrame;
+import javax.swing.JOptionPane;
+
+import tim.prune.data.DataPoint;
+import tim.prune.data.Field;
+import tim.prune.data.Track;
+import tim.prune.data.TrackInfo;
+import tim.prune.gui.MenuManager;
+import tim.prune.gui.UndoManager;
+import tim.prune.load.FileLoader;
+import tim.prune.save.FileSaver;
+import tim.prune.save.KmlExporter;
+import tim.prune.undo.UndoCompress;
+import tim.prune.undo.UndoDeleteDuplicates;
+import tim.prune.undo.UndoDeletePoint;
+import tim.prune.undo.UndoDeleteRange;
+import tim.prune.undo.UndoException;
+import tim.prune.undo.UndoInsert;
+import tim.prune.undo.UndoLoad;
+import tim.prune.undo.UndoOperation;
+import tim.prune.undo.UndoRearrangeWaypoints;
+import tim.prune.undo.UndoReverseSection;
+
+
+/**
+ * Main controller for the application
+ */
+public class App
+{
+       // Instance variables
+       private JFrame _frame = null;
+       private Track _track = null;
+       private TrackInfo _trackInfo = null;
+       private int _lastSavePosition = 0;
+       private MenuManager _menuManager = null;
+       private FileLoader _fileLoader = null;
+       private Stack _undoStack = null;
+       private UpdateMessageBroker _broker = null;
+       private boolean _reversePointsConfirmed = false;
+
+       // Constants
+       public static final int REARRANGE_TO_START   = 0;
+       public static final int REARRANGE_TO_END     = 1;
+       public static final int REARRANGE_TO_NEAREST = 2;
+
+
+       // TODO: Make waypoint window to allow list of waypoints, edit names etc
+
+       /**
+        * Constructor
+        * @param inFrame frame object for application
+        * @param inBroker message broker
+        */
+       public App(JFrame inFrame, UpdateMessageBroker inBroker)
+       {
+               _frame = inFrame;
+               _undoStack = new Stack();
+               _broker = inBroker;
+               _track = new Track(_broker);
+               _trackInfo = new TrackInfo(_track, _broker);
+       }
+
+
+       /**
+        * @return the current TrackInfo
+        */
+       public TrackInfo getTrackInfo()
+       {
+               return _trackInfo;
+       }
+
+       /**
+        * Check if the application has unsaved data
+        * @return true if data is unsaved, false otherwise
+        */
+       public boolean hasDataUnsaved()
+       {
+               return _undoStack.size() > _lastSavePosition;
+       }
+
+       /**
+        * @return the undo stack
+        */
+       public Stack getUndoStack()
+       {
+               return _undoStack;
+       }
+
+       /**
+        * Set the MenuManager object to be informed about changes
+        * @param inManager MenuManager object
+        */
+       public void setMenuManager(MenuManager inManager)
+       {
+               _menuManager = inManager;
+       }
+
+
+       /**
+        * Open a file containing track or waypoint data
+        */
+       public void openFile()
+       {
+               if (_fileLoader == null)
+                       _fileLoader = new FileLoader(this, _frame);
+               _fileLoader.openFile();
+       }
+
+
+       /**
+        * Save the file in the selected format
+        */
+       public void saveFile()
+       {
+               if (_track == null)
+               {
+                       JOptionPane.showMessageDialog(_frame, I18nManager.getText("error.save.nodata"),
+                               I18nManager.getText("error.save.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+               }
+               else
+               {
+                       FileSaver saver = new FileSaver(this, _frame, _track);
+                       saver.showDialog(_fileLoader.getLastUsedDelimiter());
+               }
+       }
+
+
+       /**
+        * Export track data as Kml
+        */
+       public void exportKml()
+       {
+               if (_track == null)
+               {
+                       JOptionPane.showMessageDialog(_frame, I18nManager.getText("error.save.nodata"),
+                               I18nManager.getText("error.save.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+               }
+               else
+               {
+                       KmlExporter exporter = new KmlExporter(this, _frame, _track);
+                       exporter.showDialog();
+               }
+       }
+
+
+       /**
+        * Exit the application if confirmed
+        */
+       public void exit()
+       {
+               // check if ok to exit
+               Object[] buttonTexts = {I18nManager.getText("button.exit"), I18nManager.getText("button.cancel")};
+               if (!hasDataUnsaved()
+                       || JOptionPane.showOptionDialog(_frame, I18nManager.getText("dialog.exit.confirm.text"),
+                               I18nManager.getText("dialog.exit.confirm.title"), JOptionPane.YES_NO_OPTION,
+                               JOptionPane.WARNING_MESSAGE, null, buttonTexts, buttonTexts[1])
+                       == JOptionPane.YES_OPTION)
+               {
+                       System.exit(0);
+               }
+       }
+
+
+       /**
+        * Delete the currently selected point
+        */
+       public void deleteCurrentPoint()
+       {
+               if (_track != null)
+               {
+                       DataPoint currentPoint = _trackInfo.getCurrentPoint();
+                       if (currentPoint != null)
+                       {
+                               // add information to undo stack
+                               int pointIndex = _trackInfo.getSelection().getCurrentPointIndex();
+                               UndoOperation undo = new UndoDeletePoint(pointIndex, currentPoint);
+                               // call track to delete point
+                               if (_trackInfo.deletePoint())
+                               {
+                                       _undoStack.push(undo);
+                               }
+                       }
+               }
+       }
+
+
+       /**
+        * Delete the currently selected range
+        */
+       public void deleteSelectedRange()
+       {
+               if (_track != null)
+               {
+                       // add information to undo stack
+                       UndoOperation undo = new UndoDeleteRange(_trackInfo);
+                       // call track to delete point
+                       if (_trackInfo.deleteRange())
+                       {
+                               _undoStack.push(undo);
+                       }
+               }
+       }
+
+
+       /**
+        * Delete all the duplicate points in the track
+        */
+       public void deleteDuplicates()
+       {
+               if (_track != null)
+               {
+                       // Save undo information
+                       UndoOperation undo = new UndoDeleteDuplicates(_track);
+                       // tell track to do it
+                       int numDeleted = _trackInfo.deleteDuplicates();
+                       if (numDeleted > 0)
+                       {
+                               _undoStack.add(undo);
+                               String message = null;
+                               if (numDeleted == 1)
+                               {
+                                       message = "1 " + I18nManager.getText("dialog.deleteduplicates.single.text");
+                               }
+                               else
+                               {
+                                       message = "" + numDeleted + " " + I18nManager.getText("dialog.deleteduplicates.multi.text");
+                               }
+                               JOptionPane.showMessageDialog(_frame, message,
+                                       I18nManager.getText("dialog.deleteduplicates.title"), JOptionPane.INFORMATION_MESSAGE);
+                       }
+                       else
+                       {
+                               JOptionPane.showMessageDialog(_frame,
+                                       I18nManager.getText("dialog.deleteduplicates.nonefound"),
+                                       I18nManager.getText("dialog.deleteduplicates.title"), JOptionPane.INFORMATION_MESSAGE);
+                       }
+               }
+       }
+
+
+       /**
+        * Compress the track
+        */
+       public void compressTrack()
+       {
+               UndoCompress undo = new UndoCompress(_track);
+               // Get compression parameter
+               Object compParam = JOptionPane.showInputDialog(_frame,
+                       I18nManager.getText("dialog.compresstrack.parameter.text"),
+                       I18nManager.getText("dialog.compresstrack.title"),
+                       JOptionPane.QUESTION_MESSAGE, null, null, "100");
+               int compNumber = parseNumber(compParam);
+               if (compNumber <= 0) return;
+               // call track to do compress
+               int numPointsDeleted = _trackInfo.compress(compNumber);
+               // add to undo stack if successful
+               if (numPointsDeleted > 0)
+               {
+                       undo.setNumPointsDeleted(numPointsDeleted);
+                       _undoStack.add(undo);
+                       JOptionPane.showMessageDialog(_frame,
+                               I18nManager.getText("dialog.compresstrack.text") + " - "
+                                + numPointsDeleted + " "
+                                + (numPointsDeleted==1?I18nManager.getText("dialog.compresstrack.single.text"):I18nManager.getText("dialog.compresstrack.multi.text")),
+                               I18nManager.getText("dialog.compresstrack.title"), JOptionPane.INFORMATION_MESSAGE);
+               }
+               else
+               {
+                       JOptionPane.showMessageDialog(_frame, I18nManager.getText("dialog.compresstrack.nonefound"),
+                               I18nManager.getText("dialog.compresstrack.title"), JOptionPane.WARNING_MESSAGE);
+               }
+       }
+
+
+       /**
+        * Reverse a section of the track
+        */
+       public void reverseRange()
+       {
+               // check whether Timestamp field exists, and if so confirm reversal
+               int selStart = _trackInfo.getSelection().getStart();
+               int selEnd = _trackInfo.getSelection().getEnd();
+               if (!_track.hasData(Field.TIMESTAMP, selStart, selEnd)
+                       || _reversePointsConfirmed
+                       || (JOptionPane.showConfirmDialog(_frame,
+                                I18nManager.getText("dialog.confirmreversetrack.text"),
+                                I18nManager.getText("dialog.confirmreversetrack.title"),
+                                JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION && (_reversePointsConfirmed = true)))
+               {
+                       UndoReverseSection undo = new UndoReverseSection(selStart, selEnd);
+                       // call track to reverse range
+                       if (_track.reverseRange(selStart, selEnd))
+                       {
+                               _undoStack.add(undo);
+                       }
+               }
+       }
+
+
+       /**
+        * Interpolate the two selected points
+        */
+       public void interpolateSelection()
+       {
+               // Get number of points to add
+               Object numPointsStr = JOptionPane.showInputDialog(_frame,
+                       I18nManager.getText("dialog.interpolate.parameter.text"),
+                       I18nManager.getText("dialog.interpolate.title"),
+                       JOptionPane.QUESTION_MESSAGE, null, null, "");
+               int numPoints = parseNumber(numPointsStr);
+               if (numPoints <= 0) return;
+
+               UndoInsert undo = new UndoInsert(_trackInfo.getSelection().getStart() + 1,
+                       numPoints);
+               // call track to interpolate
+               if (_trackInfo.interpolate(numPoints))
+               {
+                       _undoStack.add(undo);
+               }
+       }
+
+
+       /**
+        * Rearrange the waypoints into track order
+        */
+       public void rearrangeWaypoints(int inFunction)
+       {
+               UndoRearrangeWaypoints undo = new UndoRearrangeWaypoints(_track);
+               boolean success = false;
+               if (inFunction == REARRANGE_TO_START || inFunction == REARRANGE_TO_END)
+               {
+                       // Collect the waypoints to the start or end of the track
+                       success = _track.collectWaypoints(inFunction == REARRANGE_TO_START);
+               }
+               else
+               {
+                       // Interleave the waypoints into track order
+                       success = _track.interleaveWaypoints();
+               }
+               if (success)
+               {
+                       _undoStack.add(undo);
+               }
+               else
+               {
+                       JOptionPane.showMessageDialog(_frame, I18nManager.getText("error.rearrange.noop"),
+                               I18nManager.getText("error.function.noop.title"), JOptionPane.WARNING_MESSAGE);
+               }
+       }
+
+
+       /**
+        * Open a new window with the 3d view
+        */
+       public void show3dWindow()
+       {
+               // TODO: open 3d view window
+               JOptionPane.showMessageDialog(_frame, I18nManager.getText("error.function.notimplemented"),
+                       I18nManager.getText("error.function.notimplemented.title"), JOptionPane.WARNING_MESSAGE);
+       }
+
+
+       /**
+        * Select all points
+        */
+       public void selectAll()
+       {
+               _trackInfo.getSelection().select(0, 0, _track.getNumPoints()-1);
+       }
+
+       /**
+        * Select nothing
+        */
+       public void selectNone()
+       {
+               _trackInfo.getSelection().clearAll();
+       }
+
+       /**
+        * Receive loaded data and optionally merge with current Track
+        * @param inFieldArray array of fields
+        * @param inDataArray array of data
+        */
+       public void informDataLoaded(Field[] inFieldArray, Object[][] inDataArray, int inAltFormat, String inFilename)
+       {
+               // Decide whether to load or append
+               if (_track != null && _track.getNumPoints() > 0)
+               {
+                       // ask whether to replace or append
+                       int answer = JOptionPane.showConfirmDialog(_frame,
+                               I18nManager.getText("dialog.openappend.text"),
+                               I18nManager.getText("dialog.openappend.title"),
+                               JOptionPane.YES_NO_CANCEL_OPTION);
+                       if (answer == JOptionPane.YES_OPTION)
+                       {
+                               // append data to current Track
+                               Track loadedTrack = new Track(_broker);
+                               loadedTrack.load(inFieldArray, inDataArray, inAltFormat);
+                               _undoStack.add(new UndoLoad(_track.getNumPoints(), loadedTrack.getNumPoints()));
+                               _track.combine(loadedTrack);
+                               _trackInfo.getFileInfo().addFile();
+                       }
+                       else if (answer == JOptionPane.NO_OPTION)
+                       {
+                               // Don't append, replace data
+                               _undoStack.add(new UndoLoad(_trackInfo, inDataArray.length));
+                               _lastSavePosition = _undoStack.size();
+                               _trackInfo.loadTrack(inFieldArray, inDataArray, inAltFormat);
+                               _trackInfo.getFileInfo().setFile(inFilename);
+                       }
+               }
+               else
+               {
+                       // currently no data held, so use received data
+                       _undoStack.add(new UndoLoad(_trackInfo, inDataArray.length));
+                       _lastSavePosition = _undoStack.size();
+                       _trackInfo.loadTrack(inFieldArray, inDataArray, inAltFormat);
+                       _trackInfo.getFileInfo().setFile(inFilename);
+               }
+               _broker.informSubscribers();
+               // update menu
+               _menuManager.informFileLoaded();
+       }
+
+
+       /**
+        * Inform the app that the data has been saved
+        */
+       public void informDataSaved()
+       {
+               _lastSavePosition = _undoStack.size();
+       }
+
+
+       /**
+        * Begin undo process
+        */
+       public void beginUndo()
+       {
+               if (_undoStack.isEmpty())
+               {
+                       JOptionPane.showMessageDialog(_frame, I18nManager.getText("dialog.undo.none.text"),
+                               I18nManager.getText("dialog.undo.none.title"), JOptionPane.INFORMATION_MESSAGE);
+               }
+               else
+               {
+                       new UndoManager(this, _frame);
+               }
+       }
+
+
+       /**
+        * Clear the undo stack (losing all undo information
+        */
+       public void clearUndo()
+       {
+               // Exit if nothing to undo
+               if (_undoStack == null || _undoStack.isEmpty())
+                       return;
+               // Has track got unsaved data?
+               boolean unsaved = hasDataUnsaved();
+               // Confirm operation with dialog
+               int answer = JOptionPane.showConfirmDialog(_frame,
+                       I18nManager.getText("dialog.clearundo.text"),
+                       I18nManager.getText("dialog.clearundo.title"),
+                       JOptionPane.YES_NO_OPTION);
+               if (answer == JOptionPane.YES_OPTION)
+               {
+                       _undoStack.clear();
+                       _lastSavePosition = 0;
+                       if (unsaved) _lastSavePosition = -1;
+                       _broker.informSubscribers();
+               }
+       }
+
+
+       /**
+        * Undo the specified number of actions
+        * @param inNumUndos number of actions to undo
+        */
+       public void undoActions(int inNumUndos)
+       {
+               try
+               {
+                       for (int i=0; i<inNumUndos; i++)
+                       {
+                               ((UndoOperation) _undoStack.pop()).performUndo(_trackInfo);
+                       }
+                       JOptionPane.showMessageDialog(_frame, "" + inNumUndos + " "
+                                + (inNumUndos==1?I18nManager.getText("dialog.confirmundo.single.text"):I18nManager.getText("dialog.confirmundo.multiple.text")),
+                               I18nManager.getText("dialog.confirmundo.title"),
+                               JOptionPane.INFORMATION_MESSAGE);
+               }
+               catch (UndoException ue)
+               {
+                       JOptionPane.showMessageDialog(_frame,
+                               I18nManager.getText("error.undofailed.text") + " : " + ue.getMessage(),
+                               I18nManager.getText("error.undofailed.title"),
+                               JOptionPane.ERROR_MESSAGE);
+                       _undoStack.clear();
+                       _broker.informSubscribers();
+               }
+               catch (EmptyStackException empty) {}
+       }
+
+
+       /**
+        * Helper method to parse an Object into an integer
+        * @param inObject object, eg from dialog
+        * @return int value given
+        */
+       private static int parseNumber(Object inObject)
+       {
+               int num = 0;
+               if (inObject != null)
+               {
+                       try
+                       {
+                               num = Integer.parseInt(inObject.toString());
+                       }
+                       catch (NumberFormatException nfe)
+                       {}
+               }
+               return num;
+       }
+}
diff --git a/tim/prune/DataSubscriber.java b/tim/prune/DataSubscriber.java
new file mode 100644 (file)
index 0000000..ae6c3be
--- /dev/null
@@ -0,0 +1,14 @@
+package tim.prune;
+
+/**
+ * Interface implemented by clients who want to know
+ * about changes made to the data or its selection
+ */
+public interface DataSubscriber
+{
+       /**
+        * Inform clients that data has been updated
+        */
+       public void dataUpdated();
+
+}
diff --git a/tim/prune/GpsPruner.java b/tim/prune/GpsPruner.java
new file mode 100644 (file)
index 0000000..f76787c
--- /dev/null
@@ -0,0 +1,95 @@
+package tim.prune;
+
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.util.Locale;
+
+import javax.swing.JFrame;
+import javax.swing.JSplitPane;
+import javax.swing.WindowConstants;
+
+import tim.prune.gui.DetailsDisplay;
+import tim.prune.gui.MapChart;
+import tim.prune.gui.MenuManager;
+import tim.prune.gui.ProfileChart;
+
+/**
+ * Tool to visualize, edit and prune GPS data
+ */
+public class GpsPruner
+{
+       public static final String VERSION_NUMBER = "1";
+       public static final String BUILD_NUMBER = "041";
+       private static App APP = null;
+
+
+       /**
+        * Main method
+        * @param args command line arguments
+        */
+       public static void main(String[] args)
+       {
+               Locale locale = null;
+               if (args.length == 1)
+               {
+                       if (args[0].startsWith("--locale="))
+                       {
+                               if (args[0].length() == 11)
+                                       locale = new Locale(args[0].substring(9));
+                               else if (args[0].length() == 14)
+                                       locale = new Locale(args[0].substring(9, 11), args[0].substring(12));
+                               else
+                                       System.out.println("Unrecognised locale '" + args[0].substring(9)
+                                               + "' - locale should be eg 'DE' or 'DE_ch'");
+                       }
+                       else
+                               System.out.println("Unknown parameter '" + args[0] +
+                                       "'. Possible parameters:\n   --locale=  used for overriding locale settings\n");
+               }
+               I18nManager.init(locale);
+               launch();
+       }
+
+
+       /**
+        * Launch the main application
+        */
+       private static void launch()
+       {
+               JFrame frame = new JFrame("Prune");
+               UpdateMessageBroker broker = new UpdateMessageBroker();
+               APP = new App(frame, broker);
+
+               // make menu
+               MenuManager menuManager = new MenuManager(frame, APP, APP.getTrackInfo());
+               frame.setJMenuBar(menuManager.createMenuBar());
+               APP.setMenuManager(menuManager);
+               broker.addSubscriber(menuManager);
+
+               // Make three GUI components and add as listeners
+               DetailsDisplay leftPanel = new DetailsDisplay(APP, APP.getTrackInfo());
+               broker.addSubscriber(leftPanel);
+               MapChart mapDisp = new MapChart(APP, APP.getTrackInfo());
+               broker.addSubscriber(mapDisp);
+               ProfileChart profileDisp = new ProfileChart(APP.getTrackInfo());
+               broker.addSubscriber(profileDisp);
+
+               JSplitPane rightPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, mapDisp, profileDisp);
+               rightPane.setResizeWeight(1.0); // allocate as much space as poss to map
+               frame.getContentPane().add(new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftPanel,
+                               rightPane));
+               // add closing listener
+               frame.addWindowListener(new WindowAdapter() {
+                       public void windowClosing(WindowEvent e) {
+                               APP.exit();
+                       }
+               });
+               // Avoid automatically shutting down if window closed
+               frame.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
+
+               // finish off and display frame
+               frame.pack();
+               frame.setSize(600, 450);
+               frame.show();
+       }
+}
diff --git a/tim/prune/I18nManager.java b/tim/prune/I18nManager.java
new file mode 100644 (file)
index 0000000..c056fe2
--- /dev/null
@@ -0,0 +1,76 @@
+package tim.prune;
+
+import java.util.Locale;
+import java.util.MissingResourceException;
+import java.util.ResourceBundle;
+
+/**
+ * Manager for all internationalization
+ * Responsible for loading property files
+ * and delivering language-specific texts
+ */
+public abstract class I18nManager
+{
+       private static final String BUNDLE_NAME = "tim.prune.lang.prune-texts";
+       private static final Locale BACKUP_LOCALE = new Locale("en", "GB");
+
+       private static ResourceBundle EnglishTexts = null;
+       private static ResourceBundle ExtraTexts = null;
+
+
+       /**
+        * Initialize the library
+        * using the (optional) locale
+        */
+       public static void init(Locale inLocale)
+       {
+               // Load English texts first to use as defaults
+               EnglishTexts = ResourceBundle.getBundle(BUNDLE_NAME, BACKUP_LOCALE);
+
+               // Get bundle for selected locale, if any
+               if (inLocale != null)
+               {
+                       ExtraTexts = ResourceBundle.getBundle(BUNDLE_NAME, inLocale);
+               }
+               else
+               {
+                       // locale is null so just use the system default
+                       ExtraTexts = ResourceBundle.getBundle(BUNDLE_NAME, Locale.getDefault());
+               }
+       }
+
+
+       /**
+        * Lookup the given key and return the associated text
+        * @param inKey key to lookup
+        * @return associated text, or the key if not found
+        */
+       public static String getText(String inKey)
+       {
+               String value = null;
+               // look in extra texts if available
+               if (ExtraTexts != null)
+               {
+                       try
+                       {
+                               value = ExtraTexts.getString(inKey);
+                               if (value != null && !value.equals(""))
+                                       return value;
+                       }
+                       catch (MissingResourceException mre) {}
+               }
+               // look in english texts
+               if (EnglishTexts != null)
+               {
+                       try
+                       {
+                               value = EnglishTexts.getString(inKey);
+                               if (value != null && !value.equals(""))
+                                       return value;
+                       }
+                       catch (MissingResourceException mre) {}
+               }
+               // return the key itself
+               return inKey;
+       }
+}
diff --git a/tim/prune/UpdateMessageBroker.java b/tim/prune/UpdateMessageBroker.java
new file mode 100644 (file)
index 0000000..f2dfd2a
--- /dev/null
@@ -0,0 +1,49 @@
+package tim.prune;
+
+/**
+ * Class responsible for distributing update information
+ * to all registered listeners
+ */
+public class UpdateMessageBroker
+{
+       private DataSubscriber[] _subscribers;
+       private int _subscriberNum = 0;
+       private static final int MAXIMUM_NUMBER_SUBSCRIBERS = 4;
+
+
+       /**
+        * Constructor
+        * @param inTrack Track object
+        */
+       public UpdateMessageBroker()
+       {
+               _subscribers = new DataSubscriber[MAXIMUM_NUMBER_SUBSCRIBERS];
+       }
+
+
+       /**
+        * Add a data subscriber to the list
+        * @param inSub DataSubscriber to add
+        */
+       public void addSubscriber(DataSubscriber inSub)
+       {
+               _subscribers[_subscriberNum] = inSub;
+               _subscriberNum++;
+       }
+
+
+       /**
+        * Send a message to all subscribers that
+        * the data has been updated
+        */
+       public void informSubscribers()
+       {
+               for (int i=0; i<_subscribers.length; i++)
+               {
+                       if (_subscribers[i] != null)
+                       {
+                               _subscribers[i].dataUpdated();
+                       }
+               }
+       }
+}
diff --git a/tim/prune/data/Altitude.java b/tim/prune/data/Altitude.java
new file mode 100644 (file)
index 0000000..73552ae
--- /dev/null
@@ -0,0 +1,113 @@
+package tim.prune.data;
+
+/**
+ * Class to hold an altitude and provide conversion functions
+ */
+public class Altitude
+{
+       private boolean _valid = false;
+       private int _value = 0;
+       private int _format = -1;
+       public static final int FORMAT_NONE   = -1;
+       public static final int FORMAT_METRES = 0;
+       public static final int FORMAT_FEET = 1;
+
+       private static final double CONVERT_FEET_TO_METRES = 0.3048;
+       private static final double CONVERT_METRES_TO_FEET = 3.28084;
+
+
+       /**
+        * Constructor
+        */
+       public Altitude(String inString, int inFormat)
+       {
+               if (inString != null && !inString.equals(""))
+               {
+                       try
+                       {
+                               _value = Integer.parseInt(inString.trim());
+                               _format = inFormat;
+                               _valid = true;
+                       }
+                       catch (NumberFormatException nfe) {}
+               }
+       }
+
+
+       /**
+        * Constructor
+        */
+       public Altitude(int inValue, int inFormat)
+       {
+               _value = inValue;
+               _format = inFormat;
+               _valid = true;
+       }
+
+
+       /**
+        * @return true if the value could be parsed
+        */
+       public boolean isValid()
+       {
+               return _valid;
+       }
+
+
+       /**
+        * @return raw value as int
+        */
+       public int getValue()
+       {
+               return _value;
+       }
+
+
+       /**
+        * @return format of number
+        */
+       public int getFormat()
+       {
+               return _format;
+       }
+
+
+       /**
+        * Get the altitude value in the specified format
+        * @param inFormat desired format, either FORMAT_METRES or FORMAT_FEET
+        * @return value as an int
+        */
+       public int getValue(int inFormat)
+       {
+               if (inFormat == _format)
+                       return _value;
+               if (inFormat == FORMAT_METRES)
+                       return (int) (_value * CONVERT_FEET_TO_METRES);
+               if (inFormat == FORMAT_FEET)
+                       return (int) (_value * CONVERT_METRES_TO_FEET);
+               return _value;
+       }
+
+
+       /**
+        * Interpolate a new Altitude object between the given ones
+        * @param inStart start altitude
+        * @param inEnd end altitude
+        * @param inIndex index of interpolated point
+        * @param inNumSteps number of steps to interpolate
+        * @return Interpolated Altitude object
+        */
+       public static Altitude interpolate(Altitude inStart, Altitude inEnd, int inIndex, int inNumSteps)
+       {
+               // Check if altitudes are valid
+               if (inStart == null || inEnd == null || !inStart.isValid() || !inEnd.isValid())
+                       return new Altitude(null, FORMAT_NONE);
+               // Use altitude format of first point
+               int altFormat = inStart.getFormat();
+               int startValue = inStart.getValue();
+               int endValue = inEnd.getValue(altFormat);
+               int newValue = startValue
+                       + (int) ((endValue - startValue) * 1.0 / (inNumSteps + 1) * (inIndex + 1));
+               return new Altitude(newValue, altFormat);
+       }
+}
diff --git a/tim/prune/data/AltitudeRange.java b/tim/prune/data/AltitudeRange.java
new file mode 100644 (file)
index 0000000..5b606f4
--- /dev/null
@@ -0,0 +1,65 @@
+package tim.prune.data;
+
+/**
+ * Represents a range of altitudes, taking units into account.
+ * Values assumed to be >= 0.
+ */
+public class AltitudeRange
+{
+       private IntegerRange _range = new IntegerRange();
+       private int _format = Altitude.FORMAT_NONE;
+
+
+       /**
+        * Add a value to the range
+        * @param inValue value to add, only positive values considered
+        */
+       public void addValue(Altitude inAltitude)
+       {
+               if (inAltitude != null)
+               {
+                       int altValue = inAltitude.getValue(_format);
+                       _range.addValue(altValue);
+                       if (_format == Altitude.FORMAT_NONE)
+                       {
+                               _format = inAltitude.getFormat();
+                       }
+               }
+       }
+
+
+       /**
+        * @return true if positive data values were found
+        */
+       public boolean hasData()
+       {
+               return (_range.hasData());
+       }
+
+
+       /**
+        * @return minimum value, or -1 if none found
+        */
+       public int getMinimum()
+       {
+               return _range.getMinimum();
+       }
+
+
+       /**
+        * @return maximum value, or -1 if none found
+        */
+       public int getMaximum()
+       {
+               return _range.getMaximum();
+       }
+
+
+       /**
+        * @return the altitude format used
+        */
+       public int getFormat()
+       {
+               return _format;
+       }
+}
diff --git a/tim/prune/data/Coordinate.java b/tim/prune/data/Coordinate.java
new file mode 100644 (file)
index 0000000..e297a8d
--- /dev/null
@@ -0,0 +1,279 @@
+package tim.prune.data;
+
+/**
+ * Class to represent a lat/long coordinate
+ * and provide conversion functions
+ */
+public abstract class Coordinate
+{
+       public static final int NORTH = 0;
+       public static final int EAST = 1;
+       public static final int SOUTH = 2;
+       public static final int WEST = 3;
+       public static final char[] PRINTABLE_CARDINALS = {'N', 'E', 'S', 'W'};
+       public static final int FORMAT_DEG_MIN_SEC = 10;
+       public static final int FORMAT_DEG_MIN = 11;
+       public static final int FORMAT_DEG = 12;
+       public static final int FORMAT_DEG_WITHOUT_CARDINAL = 13;
+       public static final int FORMAT_NONE = 19;
+
+       // Instance variables
+       private boolean _valid = false;
+       protected int _cardinal = NORTH;
+       private int _degrees = 0;
+       private int _minutes = 0;
+       private int _seconds = 0;
+       private int _fracs = 0;
+       private String _originalString = null;
+       private int _originalFormat = FORMAT_NONE;
+       private double _asDouble = 0.0;
+
+
+       /**
+        * Constructor given String
+        * @param inString string to parse
+        */
+       public Coordinate(String inString)
+       {
+               _originalString = inString;
+               int strLen = 0;
+               if (inString != null)
+               {
+                       inString = inString.trim();
+                       strLen = inString.length();
+               }
+               if (strLen > 1)
+               {
+                       // Check for leading character NSEW
+                       _cardinal = getCardinal(inString.charAt(0));
+                       // count numeric fields - 1=d, 2=dm, 3=dm.m/dms, 4=dms.s
+                       int numFields = 0;
+                       boolean inNumeric = false;
+                       char currChar;
+                       long[] fields = new long[4];
+                       long[] denoms = new long[4];
+                       try
+                       {
+                               for (int i=0; i<strLen; i++)
+                               {
+                                       currChar = inString.charAt(i);
+                                       if (currChar >= '0' && currChar <= '9')
+                                       {
+                                               if (!inNumeric)
+                                               {
+                                                       inNumeric = true;
+                                                       numFields++;
+                                                       denoms[numFields-1] = 1;
+                                               }
+                                               fields[numFields-1] = fields[numFields-1] * 10 + (currChar - '0');
+                                               denoms[numFields-1] *= 10;
+                                       }
+                                       else
+                                       {
+                                               inNumeric = false;
+                                       }
+                               }
+                               _valid = (numFields > 0);
+                       }
+                       catch (ArrayIndexOutOfBoundsException obe)
+                       {
+                               // more than four fields found - unable to parse
+                               _valid = false;
+                       }
+                       // parse fields according to number found
+                       _degrees = (int) fields[0];
+                       _originalFormat = FORMAT_DEG;
+                       if (numFields == 2)
+                       {
+                               // String is just decimal degrees
+                               double numMins = fields[1] * 60.0 / denoms[1];
+                               _minutes = (int) numMins;
+                               double numSecs = (numMins - _minutes) * 60.0;
+                               _seconds = (int) numSecs;
+                               _fracs = (int) ((numSecs - _seconds) * 10);
+                       }
+                       else if (numFields == 3)
+                       {
+                               // String is degrees-minutes.fractions
+                               _originalFormat = FORMAT_DEG_MIN;
+                               _minutes = (int) fields[1];
+                               double numSecs = fields[2] * 60.0 / denoms[2];
+                               _seconds = (int) numSecs;
+                               _fracs = (int) ((numSecs - _seconds) * 10);
+                       }
+                       else if (numFields == 4)
+                       {
+                               _originalFormat = FORMAT_DEG_MIN_SEC;
+                               // String is degrees-minutes-seconds.fractions
+                               _minutes = (int) fields[1];
+                               _seconds = (int) fields[2];
+                               _fracs = (int) fields[3];
+                       }
+                       _asDouble = 1.0 * _degrees + (_minutes / 60.0) + (_seconds / 3600.0) + (_fracs / 36000.0);
+                       if (_cardinal == WEST || _cardinal == SOUTH || inString.charAt(0) == '-')
+                               _asDouble = -_asDouble;
+               }
+               else _valid = false;
+       }
+
+
+       /**
+        * Get the cardinal from the given character
+        * @param inChar character from file
+        */
+       protected abstract int getCardinal(char inChar);
+
+
+       /**
+        * Constructor
+        * @param inValue value of coordinate
+        * @param inFormat format to use
+        */
+       protected Coordinate(double inValue, int inFormat)
+       {
+               _asDouble = inValue;
+               // Calculate degrees, minutes, seconds
+               _degrees = (int) inValue;
+               double numMins = (Math.abs(_asDouble)-Math.abs(_degrees)) * 60.0;
+               _minutes = (int) numMins;
+               double numSecs = (numMins - _minutes) * 60.0;
+               _seconds = (int) numSecs;
+               _fracs = (int) ((numSecs - _seconds) * 10);
+               // Make a string to display on screen
+               _originalFormat = FORMAT_NONE;
+               if (inFormat == FORMAT_NONE) inFormat = FORMAT_DEG_WITHOUT_CARDINAL;
+               _originalString = output(inFormat);
+               _originalFormat = inFormat;
+       }
+
+
+       /**
+        * @return coordinate as a double
+        */
+       public double getDouble()
+       {
+               return _asDouble;
+       }
+
+       /**
+        * @return true if Coordinate is valid
+        */
+       public boolean isValid()
+       {
+               return _valid;
+       }
+
+       /**
+        * Compares two Coordinates for equality
+        * @param inOther other Coordinate object with which to compare
+        * @return true if the two objects are equal
+        */
+       public boolean equals(Coordinate inOther)
+       {
+               return (inOther != null && _cardinal == inOther._cardinal
+                       && _degrees == inOther._degrees
+                       && _minutes == inOther._minutes
+                       && _seconds == inOther._seconds
+                       && _fracs == inOther._fracs);
+       }
+
+
+       /**
+        * Output the Coordinate in the given format
+        * @param inOriginalString the original String to use as default
+        * @param inFormat format to use, eg FORMAT_DEG_MIN_SEC
+        * @return String for output
+        */
+       public String output(int inFormat)
+       {
+               String answer = _originalString;
+               if (inFormat != FORMAT_NONE && inFormat != _originalFormat)
+               {
+                       // TODO: allow specification of precision for output of d-m and d
+                       // format as specified
+                       switch (inFormat)
+                       {
+                               case FORMAT_DEG_MIN_SEC: {
+                                       StringBuffer buffer = new StringBuffer();
+                                       buffer.append(PRINTABLE_CARDINALS[_cardinal])
+                                               .append(threeDigitString(_degrees)).append('°')
+                                               .append(twoDigitString(_minutes)).append('\'')
+                                               .append(twoDigitString(_seconds)).append('.')
+                                               .append(_fracs);
+                                       answer = buffer.toString(); break;
+                               }
+                               case FORMAT_DEG_MIN: answer = "" + PRINTABLE_CARDINALS[_cardinal] + threeDigitString(_degrees) + "°"
+                                       + (_minutes + _seconds / 60.0 + _fracs / 600.0); break;
+                               case FORMAT_DEG:
+                               case FORMAT_DEG_WITHOUT_CARDINAL: answer = (_asDouble<0.0?"-":"")
+                               + (_degrees + _minutes / 60.0 + _seconds / 3600.0 + _fracs / 36000.0); break;
+                       }
+               }
+               return answer;
+       }
+
+
+       /**
+        * Format an integer to a two-digit String
+        * @param inNumber number to format
+        * @return two-character String
+        */
+       private static String twoDigitString(int inNumber)
+       {
+               if (inNumber <= 0) return "00";
+               if (inNumber < 10) return "0" + inNumber;
+               if (inNumber < 100) return "" + inNumber;
+               return "" + (inNumber % 100);
+       }
+
+
+       /**
+        * Format an integer to a three-digit String for degrees
+        * @param inNumber number to format
+        * @return three-character String
+        */
+       private static String threeDigitString(int inNumber)
+       {
+               if (inNumber <= 0) return "000";
+               if (inNumber < 10) return "00" + inNumber;
+               if (inNumber < 100) return "0" + inNumber;
+               return "" + (inNumber % 1000);
+       }
+
+
+       /**
+        * Create a new Coordinate between two others
+        * @param inStart start coordinate
+        * @param inEnd end coordinate
+        * @param inIndex index of point
+        * @param inNumPoints number of points to interpolate
+        * @return new Coordinate object
+        */
+       public static Coordinate interpolate(Coordinate inStart, Coordinate inEnd,
+               int inIndex, int inNumPoints)
+       {
+               double startValue = inStart.getDouble();
+               double endValue = inEnd.getDouble();
+               double newValue = startValue + (endValue - startValue) * (inIndex+1) / (inNumPoints + 1);
+               Coordinate answer = inStart.makeNew(newValue, inStart._originalFormat);
+               return answer;
+       }
+
+
+       /**
+        * Make a new Coordinate according to subclass
+        * @param inValue double value
+        * @param inFormat format to use
+        * @return object of Coordinate subclass
+        */
+       protected abstract Coordinate makeNew(double inValue, int inFormat);
+
+
+       /**
+        * Create a String representation for debug
+        */
+       public String toString()
+       {
+               return "Coord: " + _cardinal + " (" + _degrees + ") (" + _minutes + ") (" + _seconds + "." + _fracs + ") = " + _asDouble;
+       }
+}
diff --git a/tim/prune/data/DataPoint.java b/tim/prune/data/DataPoint.java
new file mode 100644 (file)
index 0000000..5d66ec5
--- /dev/null
@@ -0,0 +1,213 @@
+package tim.prune.data;
+
+/**
+ * Class to represent a single data point in the series
+ * including all its fields
+ * Can be either a track point or a waypoint
+ */
+public class DataPoint
+{
+       // Hold these as Strings?  Or FieldValue objects?
+       private String[] _fieldValues = null;
+       // list of fields
+       private FieldList _fieldList = null;
+       // Special fields for coordinates
+       private Coordinate _latitude = null, _longitude = null;
+       private Altitude _altitude;
+       private Timestamp _timestamp = null;
+       private boolean _pointValid = false;
+
+
+       // TODO: Make it possible to turn track point into waypoint - may need to alter FieldList
+
+       /**
+        * Constructor
+        * @param inValueArray array of String values
+        * @param inFieldList list of fields
+        * @param inAltFormat altitude format
+        */
+       public DataPoint(String[] inValueArray, FieldList inFieldList, int inAltFormat)
+       {
+               // save data
+               _fieldValues = inValueArray;
+               // save list of fields
+               _fieldList = inFieldList;
+
+               // parse fields
+               _latitude = new Latitude(getFieldValue(Field.LATITUDE));
+               _longitude = new Longitude(getFieldValue(Field.LONGITUDE));
+               _altitude = new Altitude(getFieldValue(Field.ALTITUDE), inAltFormat);
+               _timestamp = new Timestamp(getFieldValue(Field.TIMESTAMP));
+       }
+
+
+       /**
+        * Private constructor for artificially generated points (eg interpolated)
+        * @param inLatitude latitude
+        * @param inLongitude longitude
+        * @param inAltitude altitude
+        */
+       private DataPoint(Coordinate inLatitude, Coordinate inLongitude, Altitude inAltitude)
+       {
+               // Only these three fields are available
+               _fieldValues = new String[0];
+               _fieldList = new FieldList();
+               _latitude = inLatitude;
+               _longitude = inLongitude;
+               _altitude = inAltitude;
+               _timestamp = new Timestamp(null);
+       }
+
+
+       /**
+        * Get the value for the given field
+        * @param inField field to interrogate
+        * @return value of field
+        */
+       public String getFieldValue(Field inField)
+       {
+               return getFieldValue(_fieldList.getFieldIndex(inField));
+       }
+
+
+       /**
+        * Get the value at the given index
+        * @param inIndex index number starting at zero
+        * @return field value, or null if not found
+        */
+       public String getFieldValue(int inIndex)
+       {
+               if (_fieldValues == null || inIndex < 0 || inIndex >= _fieldValues.length)
+                       return null;
+               return _fieldValues[inIndex];
+       }
+
+
+       public Coordinate getLatitude()
+       {
+               return _latitude;
+       }
+       public Coordinate getLongitude()
+       {
+               return _longitude;
+       }
+       public boolean hasAltitude()
+       {
+               return _altitude.isValid();
+       }
+       public Altitude getAltitude()
+       {
+               return _altitude;
+       }
+       public boolean hasTimestamp()
+       {
+               return _timestamp.isValid();
+       }
+       public Timestamp getTimestamp()
+       {
+               return _timestamp;
+       }
+
+       /**
+        * @return true if point has a waypoint name
+        */
+       public boolean isWaypoint()
+       {
+               String name = getFieldValue(Field.WAYPT_NAME);
+               return (name != null && !name.equals(""));
+       }
+
+       /**
+        * Compare two DataPoint objects to see if they
+        * are duplicates
+        * @param inOther other object to compare
+        * @return true if the points are equivalent
+        */
+       public boolean isDuplicate(DataPoint inOther)
+       {
+               if (inOther == null) return false;
+               if (_longitude == null || _latitude == null
+                       || inOther._longitude == null || inOther._latitude == null)
+               {
+                       return false;
+               }
+               // Compare latitude and longitude
+               if (!_longitude.equals(inOther._longitude) || !_latitude.equals(inOther._latitude))
+               {
+                       return false;
+               }
+               // Note that conversion from decimal to dms can make non-identical points into duplicates
+               // Compare description (if any)
+               String name1 = getFieldValue(Field.WAYPT_NAME);
+               String name2 = inOther.getFieldValue(Field.WAYPT_NAME);
+               if (name1 == null || name1.equals(""))
+               {
+                       return (name2 == null || name2.equals(""));
+               }
+               else
+               {
+                       return (name2 != null && name2.equals(name1));
+               }
+       }
+
+
+       /**
+        * @return true if the point is valid
+        */
+       public boolean isValid()
+       {
+               return _latitude.isValid() && _longitude.isValid();
+       }
+
+
+       /**
+        * Interpolate a set of points between this one and the given one
+        * @param inEndPoint end point of interpolation
+        * @param inNumPoints number of points to generate
+        * @return the DataPoint array
+        */
+       public DataPoint[] interpolate(DataPoint inEndPoint, int inNumPoints)
+       {
+               DataPoint[] range = new DataPoint[inNumPoints];
+               Coordinate endLatitude = inEndPoint.getLatitude();
+               Coordinate endLongitude = inEndPoint.getLongitude();
+               Altitude endAltitude = inEndPoint.getAltitude();
+
+               // Loop over points
+               for (int i=0; i<inNumPoints; i++)
+               {
+                       Coordinate latitude = Coordinate.interpolate(_latitude, endLatitude, i, inNumPoints);
+                       Coordinate longitude = Coordinate.interpolate(_longitude, endLongitude, i, inNumPoints);
+                       Altitude altitude = Altitude.interpolate(_altitude, endAltitude, i, inNumPoints);
+                       range[i] = new DataPoint(latitude, longitude, altitude);
+               }
+               return range;
+       }
+
+
+       /**
+        * Calculate the number of radians between two points (for distance calculation)
+        * @param inPoint1 first point
+        * @param inPoint2 second point
+        * @return angular distance between points in radians
+        */
+       public static double calculateRadiansBetween(DataPoint inPoint1, DataPoint inPoint2)
+       {
+               if (inPoint1 == null || inPoint2 == null)
+                       return 0.0;
+               final double TO_RADIANS = Math.PI / 180.0;
+               // Get lat and long from points
+               double lat1 = inPoint1.getLatitude().getDouble() * TO_RADIANS;
+               double lat2 = inPoint2.getLatitude().getDouble() * TO_RADIANS;
+               double lon1 = inPoint1.getLongitude().getDouble() * TO_RADIANS;
+               double lon2 = inPoint2.getLongitude().getDouble() * TO_RADIANS;
+               // Formula given by Wikipedia:Great-circle_distance as follows:
+               // angle = 2 arcsin( sqrt( (sin ((lat2-lat1)/2))^^2 + cos(lat1)cos(lat2)(sin((lon2-lon1)/2))^^2))
+               double firstSine = Math.sin((lat2-lat1) / 2.0);
+               double secondSine = Math.sin((lon2-lon1) / 2.0);
+               double term2 = Math.cos(lat1) * Math.cos(lat2) * secondSine * secondSine;
+               double answer = 2 * Math.asin(Math.sqrt(firstSine*firstSine + term2));
+               // phew
+               return answer;
+       }
+}
diff --git a/tim/prune/data/Distance.java b/tim/prune/data/Distance.java
new file mode 100644 (file)
index 0000000..61afdca
--- /dev/null
@@ -0,0 +1,36 @@
+package tim.prune.data;
+
+/**
+ * Class to provide distance constants and functions
+ */
+public abstract class Distance
+{
+       // distance formats
+       public static final int UNITS_KILOMETRES = 1;
+       public static final int UNITS_MILES      = 2;
+
+       // Geographical constants
+       private static final double EARTH_RADIUS_KM = 6372.795;
+       private static final double EARTH_RADIUS_MILES = 3959.8712255;
+       // Conversion constants
+       private static final double CONVERT_KM_TO_MILES = 1.609344;
+       private static final double CONVERT_MILES_TO_KM = 0.621371192;
+
+
+       /**
+        * Convert the given angle in radians into a distance
+        * @param inAngDist angular distance in radians
+        * @param inUnits desired units, miles or km
+        * @return distance in specified format
+        */
+       public static double convertRadians(double inAngDist, int inUnits)
+       {
+               // Multiply by appropriate factor
+               if (inUnits == UNITS_MILES)
+                       return inAngDist * EARTH_RADIUS_MILES;
+               return inAngDist * EARTH_RADIUS_KM;
+       }
+
+
+       
+}
diff --git a/tim/prune/data/DoubleRange.java b/tim/prune/data/DoubleRange.java
new file mode 100644 (file)
index 0000000..b7d4771
--- /dev/null
@@ -0,0 +1,50 @@
+package tim.prune.data;
+
+/**
+ * Represents a range of doubles, holding the maximum and
+ * minimum values.  Values can be positive or negative
+ */
+public class DoubleRange
+{
+       private boolean _empty = true;
+       private double _min = 0.0, _max = 0.0;
+
+
+       /**
+        * Add a value to the range
+        * @param inValue value to add
+        */
+       public void addValue(double inValue)
+       {
+               if (inValue < _min || _empty) _min = inValue;
+               if (inValue > _max || _empty) _max = inValue;
+               _empty = false;
+       }
+
+
+       /**
+        * @return true if data values were found
+        */
+       public boolean hasData()
+       {
+               return (!_empty);
+       }
+
+
+       /**
+        * @return minimum value, or 0.0 if none found
+        */
+       public double getMinimum()
+       {
+               return _min;
+       }
+
+
+       /**
+        * @return maximum value, or 0.0 if none found
+        */
+       public double getMaximum()
+       {
+               return _max;
+       }
+}
diff --git a/tim/prune/data/Field.java b/tim/prune/data/Field.java
new file mode 100644 (file)
index 0000000..118fae3
--- /dev/null
@@ -0,0 +1,90 @@
+package tim.prune.data;
+
+import tim.prune.I18nManager;
+
+/**
+ * Class to represent a field of a data point
+ * including its type
+ */
+public class Field
+{
+       private String _labelKey = null;
+       private String _customLabel = null;
+       private FieldType _type = null;
+       private boolean _builtin = false;
+
+       public static final Field LATITUDE = new Field("fieldname.latitude", FieldType.COORD);
+       public static final Field LONGITUDE = new Field("fieldname.longitude", FieldType.COORD);
+       public static final Field ALTITUDE = new Field("fieldname.altitude", FieldType.INT);
+       public static final Field TIMESTAMP = new Field("fieldname.timestamp", FieldType.TIME);
+       public static final Field WAYPT_NAME = new Field("fieldname.waypointname", FieldType.NONE);
+       public static final Field WAYPT_TYPE = new Field("fieldname.waypointtype", FieldType.NONE);
+       public static final Field NEW_SEGMENT = new Field("fieldname.newsegment", FieldType.BOOL);
+
+       public static final Field[] ALL_AVAILABLE_FIELDS = {
+                       LATITUDE, LONGITUDE, ALTITUDE, TIMESTAMP, WAYPT_NAME, WAYPT_TYPE, NEW_SEGMENT,
+                       new Field("fieldname.custom", FieldType.NONE)
+       };
+
+       /**
+        * Private constructor
+        * @param inLabelKey Key for label texts
+        * @param inType type of field
+        */
+       private Field(String inLabelKey, FieldType inType)
+       {
+               _labelKey = inLabelKey;
+               _customLabel = null;
+               _type = inType;
+               _builtin = true;
+       }
+
+
+       /**
+        * Public constructor for custom fields
+        * @param inLabel label to use for display
+        */
+       public Field(String inLabel)
+       {
+               _labelKey = null;
+               _customLabel = inLabel;
+               _type = FieldType.NONE;
+       }
+
+       /**
+        * @return the name of the field
+        */
+       public String getName()
+       {
+               if (_labelKey != null)
+                       return I18nManager.getText(_labelKey);
+               return _customLabel;
+       }
+
+       /**
+        * Change the name of the (non built-in) field
+        * @param inName new name
+        */
+       public void setName(String inName)
+       {
+               if (!isBuiltIn()) _customLabel = inName;
+       }
+
+       /**
+        * @return true if this is a built-in field
+        */
+       public boolean isBuiltIn()
+       {
+               return _builtin;
+       }
+
+       /**
+        * Checks if the two fields are equal
+        * @param inOther other Field object
+        * @return true if Fields identical
+        */
+       public boolean equals(Field inOther)
+       {
+               return (isBuiltIn() == inOther.isBuiltIn() && getName().equals(inOther.getName()));
+       }
+}
diff --git a/tim/prune/data/FieldList.java b/tim/prune/data/FieldList.java
new file mode 100644 (file)
index 0000000..58cf6d5
--- /dev/null
@@ -0,0 +1,152 @@
+package tim.prune.data;
+
+/**
+ * Class to hold an ordered list of fields
+ * to match the value list in a data point
+ */
+public class FieldList
+{
+       private Field[] _fieldArray;
+
+
+       /**
+        * Constructor for an empty field list
+        */
+       public FieldList()
+       {
+               _fieldArray = new Field[0];
+       }
+
+       /**
+        * Constructor for a given number of empty fields
+        * @param inNumFields
+        */
+       public FieldList(int inNumFields)
+       {
+               if (inNumFields < 0) inNumFields = 0;
+               _fieldArray = new Field[inNumFields];
+       }
+
+       /**
+        * Constructor giving array of Field objects
+        * @param inFieldArray
+        */
+       public FieldList(Field[] inFieldArray)
+       {
+               if (inFieldArray == null || inFieldArray.length == 0)
+               {
+                       _fieldArray = new Field[0];
+               }
+               else
+               {
+                       _fieldArray = new Field[inFieldArray.length];
+                       System.arraycopy(inFieldArray, 0, _fieldArray, 0, inFieldArray.length);
+               }
+       }
+
+       /**
+        * Get the index of the given field
+        * @param inField field to look for
+        * @return index number of the field starting at zero
+        */
+       public int getFieldIndex(Field inField)
+       {
+               if (inField == null) return -1;
+               for (int f=0; f<_fieldArray.length; f++)
+               {
+                       if (_fieldArray[f] != null && _fieldArray[f].equals(inField))
+                               return f;
+               }
+               return -1;
+       }
+
+
+       /**
+        * Check whether the FieldList contains the given
+        * Field object
+        * @param inField Field to check
+        * @return true if the FieldList contains the given field
+        */
+       public boolean contains(Field inField)
+       {
+               return (getFieldIndex(inField) >= 0);
+       }
+
+
+       /**
+        * @return number of fields in list
+        */
+       public int getNumFields()
+       {
+               if (_fieldArray == null) return 0;
+               return _fieldArray.length;
+       }
+
+
+       /**
+        * Get the specified Field object
+        * @param inIndex index to retrieve
+        * @return Field object
+        */
+       public Field getField(int inIndex)
+       {
+               if (_fieldArray == null || inIndex < 0 || inIndex >= _fieldArray.length)
+               {
+                       return null;
+               }
+               return _fieldArray[inIndex];
+       }
+
+
+       /**
+        * Merge this list with a second list, giving a superset
+        * @param inOtherList other FieldList object to merge
+        * @return Merged FieldList object
+        */
+       public FieldList merge(FieldList inOtherList)
+       {
+               // count number of fields
+               int totalFields = _fieldArray.length;
+               for (int f=0; f<inOtherList._fieldArray.length; f++)
+               {
+                       if (inOtherList._fieldArray[f] != null && !contains(inOtherList._fieldArray[f]))
+                       {
+                               totalFields++;
+                       }
+               }
+               FieldList list = new FieldList(totalFields);
+               // copy these fields into array
+               System.arraycopy(_fieldArray, 0, list._fieldArray, 0, _fieldArray.length);
+               // copy additional fields from other array if any
+               if (totalFields > _fieldArray.length)
+               {
+                       int fieldCounter = _fieldArray.length;
+                       for (int f=0; f<inOtherList._fieldArray.length; f++)
+                       {
+                               if (inOtherList._fieldArray[f] != null && !contains(inOtherList._fieldArray[f]))
+                               {
+                                       list._fieldArray[fieldCounter] = inOtherList._fieldArray[f];
+                                       fieldCounter++;
+                               }
+                       }
+               }
+               // return the merged list
+               return list;
+       }
+
+
+       /**
+        * Convert to String for debug
+        */
+       public String toString()
+       {
+               StringBuffer buffer = new StringBuffer();
+               buffer.append("(");
+               for (int i=0; i<_fieldArray.length; i++)
+               {
+                       buffer.append(_fieldArray[i].getName()).append(',');
+               }
+               buffer.append(")");
+               return buffer.toString();
+       }
+}
diff --git a/tim/prune/data/FieldType.java b/tim/prune/data/FieldType.java
new file mode 100644 (file)
index 0000000..94ba686
--- /dev/null
@@ -0,0 +1,26 @@
+package tim.prune.data;
+
+/**
+ * Class to represent a type of field,
+ * for example coordinate or integer
+ */
+public class FieldType
+{
+       private int _id = 0;
+
+       public static final FieldType NONE =  new FieldType(0);
+       public static final FieldType INT =   new FieldType(1);
+       public static final FieldType BOOL =  new FieldType(2);
+       public static final FieldType COORD = new FieldType(3);
+       public static final FieldType TIME =  new FieldType(4);
+
+
+       /**
+        * Private constructor
+        * @param inId identifier
+        */
+       private FieldType(int inId)
+       {
+               _id = inId;
+       }
+}
diff --git a/tim/prune/data/FileInfo.java b/tim/prune/data/FileInfo.java
new file mode 100644 (file)
index 0000000..41fd10c
--- /dev/null
@@ -0,0 +1,60 @@
+package tim.prune.data;
+
+/**
+ * Class to hold the information about the file(s)
+ * from which the data was loaded from / saved to
+ */
+public class FileInfo
+{
+       private String _filename = null;
+       private int _numFiles = 0;
+
+
+       /**
+        * Set the file information to the specified file
+        * @param inFilename filename of file
+        */
+       public void setFile(String inFilename)
+       {
+               _filename = inFilename;
+               _numFiles = 1;
+       }
+
+
+       /**
+        * Add a file to the data
+        */
+       public void addFile()
+       {
+               _numFiles++;
+       }
+
+
+       /**
+        * Undo a load file
+        */
+       public void removeFile()
+       {
+               _numFiles--;
+       }
+
+
+       /**
+        * @return the number of files loaded
+        */
+       public int getNumFiles()
+       {
+               return _numFiles;
+       }
+
+
+       /**
+        * @return The filename, if a single file
+        */
+       public String getFilename()
+       {
+               if (_numFiles == 1 && _filename != null)
+                       return _filename;
+               return "";
+       }
+}
diff --git a/tim/prune/data/IntegerRange.java b/tim/prune/data/IntegerRange.java
new file mode 100644 (file)
index 0000000..61ae34c
--- /dev/null
@@ -0,0 +1,51 @@
+package tim.prune.data;
+
+/**
+ * Represents a range of integers, holding the maximum and
+ * minimum values.  Values assumed to be >= 0.
+ */
+public class IntegerRange
+{
+       private int _min = -1, _max = -1;
+
+
+       /**
+        * Add a value to the range
+        * @param inValue value to add, only positive values considered
+        */
+       public void addValue(int inValue)
+       {
+               if (inValue >= 0)
+               {
+                       if (inValue < _min || _min < 0) _min = inValue;
+                       if (inValue > _max) _max = inValue;
+               }
+       }
+
+
+       /**
+        * @return true if positive data values were found
+        */
+       public boolean hasData()
+       {
+               return (_max >= 0);
+       }
+
+
+       /**
+        * @return minimum value, or -1 if none found
+        */
+       public int getMinimum()
+       {
+               return _min;
+       }
+
+
+       /**
+        * @return maximum value, or -1 if none found
+        */
+       public int getMaximum()
+       {
+               return _max;
+       }
+}
diff --git a/tim/prune/data/Latitude.java b/tim/prune/data/Latitude.java
new file mode 100644 (file)
index 0000000..6d8bab6
--- /dev/null
@@ -0,0 +1,64 @@
+package tim.prune.data;
+
+/**
+ * Class to represent a Latitude Coordinate
+ */
+public class Latitude extends Coordinate
+{
+       /**
+        * Constructor
+        * @param inString string value from file
+        */
+       public Latitude(String inString)
+       {
+               super(inString);
+       }
+
+
+       /**
+        * Constructor
+        * @param inValue value of coordinate
+        * @param inFormat format to use
+        */
+       protected Latitude(double inValue, int inFormat)
+       {
+               super(inValue, inFormat);
+               _cardinal = inValue < 0.0 ? SOUTH : NORTH;
+       }
+
+
+       /**
+        * Turn the given character into a cardinal
+        * @see tim.prune.data.Coordinate#getCardinal(char)
+        */
+       protected int getCardinal(char inChar)
+       {
+               // Latitude recognises N, S and -
+               // default is North
+               int cardinal = NORTH;
+               switch (inChar)
+               {
+                       case 'N':
+                       case 'n':
+                               cardinal = NORTH; break;
+                       case 'S':
+                       case 's':
+                       case '-':
+                               cardinal = SOUTH; break;
+                       default:
+                               // no character given
+               }
+               return cardinal;
+       }
+
+
+       /**
+        * Make a new Latitude object
+        * @see tim.prune.data.Coordinate#makeNew(double, int)
+        */
+       protected Coordinate makeNew(double inValue, int inFormat)
+       {
+               return new Latitude(inValue, inFormat);
+       }
+
+}
diff --git a/tim/prune/data/Longitude.java b/tim/prune/data/Longitude.java
new file mode 100644 (file)
index 0000000..d61adbc
--- /dev/null
@@ -0,0 +1,64 @@
+package tim.prune.data;
+
+/**
+ * Class to represent a Longitude Coordinate
+ */
+public class Longitude extends Coordinate
+{
+       /**
+        * Constructor
+        * @param inString string value from file
+        */
+       public Longitude(String inString)
+       {
+               super(inString);
+       }
+
+
+       /**
+        * Constructor
+        * @param inValue value of coordinate
+        * @param inFormat format to use
+        */
+       protected Longitude(double inValue, int inFormat)
+       {
+               super(inValue, inFormat);
+               _cardinal = inValue < 0.0 ? WEST : EAST;
+       }
+
+
+       /**
+        * Turn the given character into a cardinal
+        * @see tim.prune.data.Coordinate#getCardinal(char)
+        */
+       protected int getCardinal(char inChar)
+       {
+               // Longitude recognises E, W and -
+               // default is East
+               int cardinal = EAST;
+               switch (inChar)
+               {
+                       case 'E':
+                       case 'e':
+                               cardinal = EAST; break;
+                       case 'W':
+                       case 'w':
+                       case '-':
+                               cardinal = WEST; break;
+                       default:
+                               // no character given
+               }
+               return cardinal;
+       }
+
+
+       /**
+        * Make a new Longitude object
+        * @see tim.prune.data.Coordinate#makeNew(double, int)
+        */
+       protected Coordinate makeNew(double inValue, int inFormat)
+       {
+               return new Longitude(inValue, inFormat);
+       }
+
+}
diff --git a/tim/prune/data/Selection.java b/tim/prune/data/Selection.java
new file mode 100644 (file)
index 0000000..c375e82
--- /dev/null
@@ -0,0 +1,404 @@
+package tim.prune.data;
+
+import tim.prune.UpdateMessageBroker;
+
+/**
+ * Class to represent a selected portion of a Track
+ * and its properties
+ */
+public class Selection
+{
+       private Track _track = null;
+       private UpdateMessageBroker _broker = null;
+       private int _currentPoint = -1;
+       private boolean _valid = false;
+       private int _startIndex = -1, _endIndex = -1;
+       private IntegerRange _altitudeRange = null;
+       private int _climb = -1, _descent = -1;
+       private int _altitudeFormat = Altitude.FORMAT_NONE;
+       private long _seconds = 0L;
+       private double _angDistance = -1.0; //, _averageSpeed = -1.0;
+
+
+       /**
+        * Constructor
+        * @param inTrack track object
+        * @param inBroker broker object
+        */
+       public Selection(Track inTrack, UpdateMessageBroker inBroker)
+       {
+               _track = inTrack;
+               _broker = inBroker;
+       }
+
+
+       /**
+        * Reset selection to be recalculated
+        */
+       private void reset()
+       {
+               _valid = false;
+       }
+
+
+       /**
+        * Select the point at the given index
+        * @param inIndex index number of selected point
+        */
+       public void selectPoint(int inIndex)
+       {
+               if (inIndex >= -1)
+               {
+                       _currentPoint = inIndex;
+                       check();
+               }
+       }
+
+       /**
+        * Select the specified point and range in one go
+        * @param inPointIndex point selection
+        * @param inStart range start
+        * @param inEnd range end
+        */
+       public void select(int inPointIndex, int inStart, int inEnd)
+       {
+               _currentPoint = inPointIndex;
+               _startIndex = inStart;
+               _endIndex = inEnd;
+               reset();
+               check();
+       }
+
+
+       /**
+        * Select the previous point
+        */
+       public void selectPreviousPoint()
+       {
+               if (_currentPoint > 0)
+                       selectPoint(_currentPoint - 1);
+       }
+
+       /**
+        * Select the next point
+        */
+       public void selectNextPoint()
+       {
+               selectPoint(_currentPoint + 1);
+       }
+
+       /**
+        * @return the current point index
+        */
+       public int getCurrentPointIndex()
+       {
+               return _currentPoint;
+       }
+
+
+       /**
+        * @return true if range is selected
+        */
+       public boolean hasRangeSelected()
+       {
+               return _startIndex >= 0 && _endIndex > _startIndex;
+       }
+
+
+       /**
+        * Recalculate all selection details
+        */
+       private void recalculate()
+       {
+               _altitudeFormat = Altitude.FORMAT_NONE;
+               if (_track.getNumPoints() > 0 && hasRangeSelected())
+               {
+                       _altitudeRange = new IntegerRange();
+                       _climb = 0;
+                       _descent = 0;
+                       Altitude altitude = null;
+                       Timestamp time = null, startTime = null, endTime = null;
+                       DataPoint lastPoint = null, currPoint = null;
+                       _angDistance = 0.0;
+                       int altValue = 0;
+                       int lastAltValue = 0;
+                       boolean foundAlt = false;
+                       for (int i=_startIndex; i<=_endIndex; i++)
+                       {
+                               currPoint = _track.getPoint(i);
+                               altitude = currPoint.getAltitude();
+                               // Ignore waypoints in altitude calculations
+                               if (!currPoint.isWaypoint() && altitude.isValid())
+                               {
+                                       altValue = altitude.getValue(_altitudeFormat);
+                                       if (_altitudeFormat == Altitude.FORMAT_NONE)
+                                               _altitudeFormat = altitude.getFormat();
+                                       _altitudeRange.addValue(altValue);
+                                       if (foundAlt)
+                                       {
+                                               if (altValue > lastAltValue)
+                                                       _climb += (altValue - lastAltValue);
+                                               else
+                                                       _descent += (lastAltValue - altValue);
+                                       }
+                                       lastAltValue = altValue;
+                                       foundAlt = true;
+                               }
+                               // Store the first and last timestamp in the range
+                               time = currPoint.getTimestamp();
+                               if (time.isValid())
+                               {
+                                       if (startTime == null) startTime = time;
+                                       endTime = time;
+                               }
+                               // Calculate distances, again ignoring waypoints
+                               if (!currPoint.isWaypoint())
+                               {
+                                       if (lastPoint != null)
+                                       {
+                                               _angDistance += DataPoint.calculateRadiansBetween(lastPoint, currPoint);
+                                       }
+                                       lastPoint = currPoint;
+                               }
+                       }
+                       if (endTime != null)
+                       {
+                               _seconds = endTime.getSecondsSince(startTime);
+                       }
+                       else
+                       {
+                               _seconds = 0L;
+                       }
+               }
+               _valid = true;
+       }
+
+
+       /**
+        * @return start index
+        */
+       public int getStart()
+       {
+               if (!_valid) recalculate();
+               return _startIndex;
+       }
+
+
+       /**
+        * @return end index
+        */
+       public int getEnd()
+       {
+               if (!_valid) recalculate();
+               return _endIndex;
+       }
+
+
+       /**
+        * @return the altitude format, ie feet or metres
+        */
+       public int getAltitudeFormat()
+       {
+               return _altitudeFormat;
+       }
+
+       /**
+        * @return altitude range
+        */
+       public IntegerRange getAltitudeRange()
+       {
+               if (!_valid) recalculate();
+               return _altitudeRange;
+       }
+
+
+       /**
+        * @return climb
+        */
+       public int getClimb()
+       {
+               if (!_valid) recalculate();
+               return _climb;
+       }
+
+       /**
+        * @return descent
+        */
+       public int getDescent()
+       {
+               if (!_valid) recalculate();
+               return _descent;
+       }
+
+
+       /**
+        * @return number of seconds spanned by selection
+        */
+       public long getNumSeconds()
+       {
+               if (!_valid) recalculate();
+               return _seconds;
+       }
+
+
+       /**
+        * @param inFormat distance units to use, from class Distance
+        * @return distance of Selection in specified units
+        */
+       public double getDistance(int inUnits)
+       {
+               return Distance.convertRadians(_angDistance, inUnits);
+       }
+
+
+       /**
+        * Clear selected point and range
+        */
+       public void clearAll()
+       {
+               _currentPoint = -1;
+               deselectRange();
+       }
+
+
+       /**
+        * Deselect range
+        */
+       public void deselectRange()
+       {
+               _startIndex = _endIndex = -1;
+               reset();
+               check();
+       }
+
+
+       /**
+        * Select the range from the current point
+        */
+       public void selectRangeStart()
+       {
+               selectRangeStart(_currentPoint);
+       }
+
+
+       /**
+        * Set the index for the start of the range selection
+        * @param inStartIndex start index
+        */
+       public void selectRangeStart(int inStartIndex)
+       {
+               if (inStartIndex < 0)
+               {
+                       _startIndex = _endIndex = -1;
+               }
+               else
+               {
+                       _startIndex = inStartIndex;
+                       // Move end of selection to max if necessary
+                       if (_endIndex <= _startIndex)
+                       {
+                               _endIndex = _track.getNumPoints() - 1;
+                       }
+               }
+               reset();
+               _broker.informSubscribers();
+       }
+
+
+       /**
+        * Select the range up to the current point
+        */
+       public void selectRangeEnd()
+       {
+               selectRangeEnd(_currentPoint);
+       }
+
+
+       /**
+        * Set the index for the end of the range selection
+        * @param inEndIndex end index
+        */
+       public void selectRangeEnd(int inEndIndex)
+       {
+               if (inEndIndex < 0)
+               {
+                       _startIndex = _endIndex = -1;
+               }
+               else
+               {
+                       _endIndex = inEndIndex;
+                       // Move start of selection to min if necessary
+                       if (_startIndex > _endIndex || _startIndex < 0)
+                       {
+                               _startIndex = 0;
+                       }
+               }
+               reset();
+               _broker.informSubscribers();
+       }
+
+
+       /**
+        * Modify the selection given that the selected range has been deleted
+        */
+       public void modifyRangeDeleted()
+       {
+               // Modify current point, if any
+               if (_currentPoint > _endIndex)
+               {
+                       _currentPoint -= (_endIndex - _startIndex);
+               }
+               else if (_currentPoint > _startIndex)
+               {
+                       _currentPoint = _startIndex;
+               }
+               // Clear selected range
+               _startIndex = _endIndex = -1;
+               // Check for consistency and fire update
+               check();
+       }
+
+
+       /**
+        * Modify the selection when a point is deleted
+        */
+       public void modifyPointDeleted()
+       {
+               // current point index doesn't change, just gets checked
+               // range needs to get altered if deleted point is inside or before
+               if (hasRangeSelected() && _currentPoint <= _endIndex)
+               {
+                       _endIndex--;
+                       if (_currentPoint < _startIndex)
+                               _startIndex--;
+                       reset();
+               }
+               check();
+       }
+
+
+       /**
+        * Check that the selection still makes sense
+        * and fire update message to listeners
+        */
+       private void check()
+       {
+               if (_track != null && _track.getNumPoints() > 0)
+               {
+                       int maxIndex = _track.getNumPoints() - 1;
+                       if (_currentPoint > maxIndex)
+                       {
+                               _currentPoint = maxIndex;
+                       }
+                       if (_endIndex > maxIndex)
+                       {
+                               _endIndex = maxIndex;
+                       }
+                       if (_startIndex > maxIndex)
+                       {
+                               _startIndex = maxIndex;
+                       }
+               }
+               _broker.informSubscribers();
+       }
+}
diff --git a/tim/prune/data/Timestamp.java b/tim/prune/data/Timestamp.java
new file mode 100644 (file)
index 0000000..965853a
--- /dev/null
@@ -0,0 +1,150 @@
+package tim.prune.data;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+
+/**
+ * Class to hold the timestamp of a track point
+ * and provide conversion functions
+ */
+public class Timestamp
+{
+       private boolean _valid = false;
+       private long _seconds = 0L;
+       private String _text = null;
+
+       private static DateFormat DEFAULT_DATE_FORMAT = DateFormat.getDateTimeInstance();
+       private static DateFormat[] ALL_DATE_FORMATS = null;
+       private static Calendar CALENDAR = null;
+       private static long SECS_SINCE_1970 = 0L;
+       private static long SECS_SINCE_GARTRIP = 0L;
+       private static long MSECS_SINCE_1970 = 0L;
+       private static long MSECS_SINCE_1990 = 0L;
+       private static long TWENTY_YEARS_IN_SECS = 0L;
+
+       private static final long GARTRIP_OFFSET = 631065600L;
+
+       // Static block to initialise offsets
+       static
+       {
+               CALENDAR = Calendar.getInstance();
+               MSECS_SINCE_1970 = CALENDAR.getTimeInMillis();
+               SECS_SINCE_1970 = MSECS_SINCE_1970 / 1000L;
+               SECS_SINCE_GARTRIP = SECS_SINCE_1970 - GARTRIP_OFFSET;
+               CALENDAR.add(Calendar.YEAR, -20);
+               MSECS_SINCE_1990 = CALENDAR.getTimeInMillis();
+               TWENTY_YEARS_IN_SECS = (MSECS_SINCE_1970 - MSECS_SINCE_1990) / 1000L;
+               // Date formats
+               ALL_DATE_FORMATS = new DateFormat[] {
+                       DEFAULT_DATE_FORMAT,
+                       new SimpleDateFormat("EEE MMM dd HH:mm:ss yyyy"),
+                       new SimpleDateFormat("HH:mm:ss dd MMM yyyy"),
+                       new SimpleDateFormat("dd MMM yyyy HH:mm:ss"),
+                       new SimpleDateFormat("yyyy MMM dd HH:mm:ss")
+               };
+       }
+
+
+       /**
+        * Constructor
+        */
+       public Timestamp(String inString)
+       {
+               if (inString != null && !inString.equals(""))
+               {
+                       // Try to parse into a long
+                       try
+                       {
+                               long rawValue = Long.parseLong(inString.trim());
+                               // check for each format possibility and pick nearest
+                               long diff1 = Math.abs(SECS_SINCE_1970 - rawValue);
+                               long diff2 = Math.abs(MSECS_SINCE_1970 - rawValue);
+                               long diff3 = Math.abs(MSECS_SINCE_1990 - rawValue);
+                               long diff4 = Math.abs(SECS_SINCE_GARTRIP - rawValue);
+
+                               // Start off with "seconds since 1970" format
+                               long smallestDiff = diff1;
+                               _seconds = rawValue;
+                               // Now check millis since 1970
+                               if (diff2 < smallestDiff)
+                               {
+                                       // milliseconds since 1970
+                                       _seconds = rawValue / 1000L;
+                                       smallestDiff = diff2;
+                               }
+                               // Now millis since 1990
+                               if (diff3 < smallestDiff)
+                               {
+                                       // milliseconds since 1990
+                                       _seconds = rawValue / 1000L + TWENTY_YEARS_IN_SECS;
+                                       smallestDiff = diff3;
+                               }
+                               // Lastly, check garmin offset
+                               if (diff4 < smallestDiff)
+                               {
+                                       // milliseconds since garmin offset
+                                       _seconds = rawValue + GARTRIP_OFFSET;
+                               }
+                               _valid = true;
+                       }
+                       catch (NumberFormatException nfe)
+                       {
+                               // String is not a long, so try a date/time string instead
+                               // try each of the date formatters in turn
+                               Date date = null;
+                               for (int i=0; i<ALL_DATE_FORMATS.length && !_valid; i++)
+                               {
+                                       try
+                                       {
+                                               date = ALL_DATE_FORMATS[i].parse(inString);
+                                               CALENDAR.setTime(date);
+                                               _seconds = CALENDAR.getTimeInMillis() / 1000L;
+                                               _valid = true;
+                                       }
+                                       catch (ParseException e) {}
+                               }
+                       }
+               }
+       }
+
+
+       /**
+        * @return true if timestamp is valid
+        */
+       public boolean isValid()
+       {
+               return _valid;
+       }
+
+
+       /**
+        * Calculate the difference between two Timestamps in seconds
+        * @param inOther other, earlier Timestamp
+        * @return number of seconds since other timestamp
+        */
+       public long getSecondsSince(Timestamp inOther)
+       {
+               return _seconds - inOther._seconds;
+       }
+
+
+       /**
+        * @return Description of timestamp in locale-specific format
+        */
+       public String getText()
+       {
+               if (_text == null)
+               {
+                       if (_valid)
+                       {
+                               CALENDAR.setTimeInMillis(_seconds * 1000L);
+                               _text = DEFAULT_DATE_FORMAT.format(CALENDAR.getTime());
+                       }
+                       else _text = "";
+               }
+               return _text;
+       }
+}
diff --git a/tim/prune/data/Track.java b/tim/prune/data/Track.java
new file mode 100644 (file)
index 0000000..683cd21
--- /dev/null
@@ -0,0 +1,793 @@
+package tim.prune.data;
+
+import tim.prune.UpdateMessageBroker;
+
+
+/**
+ * Class to hold all track information,
+ * including track points and waypoints
+ */
+public class Track
+{
+       // Broker object
+       UpdateMessageBroker _broker = null;
+       // Data points
+       private DataPoint[] _dataPoints = null;
+       // Scaled x, y values
+       private double[] _xValues = null;
+       private double[] _yValues = null;
+       private boolean _scaled = false;
+       private int _numPoints = 0;
+       private boolean _mixedData = false;
+       // Master field list
+       private FieldList _masterFieldList = null;
+       // variable ranges
+       private AltitudeRange _altitudeRange = null;
+       private DoubleRange _latRange = null, _longRange = null;
+       private DoubleRange _xRange = null, _yRange = null;
+
+
+       /**
+        * Constructor giving arrays of Fields and Objects
+        * @param inFieldArray field array
+        * @param inPointArray 2d array of field values
+        */
+       public Track(UpdateMessageBroker inBroker)
+       {
+               _broker = inBroker;
+               // create field list
+               _masterFieldList = new FieldList(null);
+               // make empty DataPoint array
+               _dataPoints = new DataPoint[0];
+               _numPoints = 0;
+               // needs to be scaled
+               _scaled = false;
+       }
+
+
+       /**
+        * Load method, for initialising and reinitialising data
+        * @param inFieldArray array of Field objects describing fields
+        * @param inPointArray 2d object array containing data
+        * @param inAltFormat altitude format
+        */
+       public void load(Field[] inFieldArray, Object[][] inPointArray, int inAltFormat)
+       {
+               // copy field list
+               _masterFieldList = new FieldList(inFieldArray);
+               // make DataPoint object from each point in inPointList
+               _dataPoints = new DataPoint[inPointArray.length];
+               String[] dataArray = null;
+               int pointIndex = 0;
+               for (int p=0; p < inPointArray.length; p++)
+               {
+                       dataArray = (String[]) inPointArray[p];
+                       // Convert to DataPoint objects
+                       DataPoint point = new DataPoint(dataArray, _masterFieldList, inAltFormat);
+                       if (point.isValid())
+                       {
+                               _dataPoints[pointIndex] = point;
+                               pointIndex++;
+                       }
+               }
+               _numPoints = pointIndex;
+               // needs to be scaled
+               _scaled = false;
+       }
+
+
+       ////////////////// Modification methods //////////////////////
+
+
+       /**
+        * Combine this Track with new data
+        * @param inOtherTrack
+        */
+       public void combine(Track inOtherTrack)
+       {
+               // merge field list
+               _masterFieldList = _masterFieldList.merge(inOtherTrack._masterFieldList);
+               // expand data array and add other track's data points
+               int totalPoints = getNumPoints() + inOtherTrack.getNumPoints();
+               DataPoint[] mergedPoints = new DataPoint[totalPoints];
+               System.arraycopy(_dataPoints, 0, mergedPoints, 0, getNumPoints());
+               System.arraycopy(inOtherTrack._dataPoints, 0, mergedPoints, getNumPoints(), inOtherTrack.getNumPoints());
+               _dataPoints = mergedPoints;
+               // combine point count
+               _numPoints = totalPoints;
+               // needs to be scaled again
+               _scaled = false;
+               // inform listeners
+               _broker.informSubscribers();
+       }
+
+
+       /**
+        * Crop the track to the given size - subsequent points are not (yet) deleted
+        * @param inNewSize new number of points in track
+        */
+       public void cropTo(int inNewSize)
+       {
+               if (inNewSize >= 0 && inNewSize < getNumPoints())
+               {
+                       _numPoints = inNewSize;
+                       // needs to be scaled again
+                       _scaled = false;
+                       _broker.informSubscribers();
+               }
+       }
+
+
+       /**
+        * Compress the track to the given resolution
+        * @param inResolution resolution
+        * @return number of points deleted
+        */
+       public int compress(int inResolution)
+       {
+               // (maybe should be separate thread?)
+               // (maybe should be in separate class?)
+               // (maybe should be based on subtended angles instead of distances?)
+
+               if (inResolution <= 0) return 0;
+               int numCopied = 0;
+               // Establish range of track and minimum range between points
+               scalePoints();
+               double wholeScale = _xRange.getMaximum() - _xRange.getMinimum();
+               double yscale = _yRange.getMaximum() - _yRange.getMinimum();
+               if (yscale > wholeScale) wholeScale = yscale;
+               double minDist = wholeScale / inResolution;
+
+               // Copy selected points
+               DataPoint[] newPointArray = new DataPoint[_numPoints];
+               int[] pointIndices = new int[_numPoints];
+               for (int i=0; i<_numPoints; i++)
+               {
+                       boolean keepPoint = true;
+                       if (!_dataPoints[i].isWaypoint())
+                       {
+                               // go through newPointArray to check for range
+                               for (int j=0; j<numCopied && keepPoint; j++)
+                               {
+                                       // calculate distance between point j and current point
+                                       double pointDist = Math.abs(_xValues[i] - _xValues[pointIndices[j]])
+                                        + Math.abs(_yValues[i] - _yValues[pointIndices[j]]);
+                                       if (pointDist < minDist)
+                                               keepPoint = false;
+                               }
+                       }
+                       if (keepPoint)
+                       {
+                               newPointArray[numCopied] = _dataPoints[i];
+                               pointIndices[numCopied] = i;
+                               numCopied++;
+                       }
+               }
+
+               // Copy array references
+               int numDeleted = _numPoints - numCopied;
+               if (numDeleted > 0)
+               {
+                       _dataPoints = new DataPoint[numCopied];
+                       System.arraycopy(newPointArray, 0, _dataPoints, 0, numCopied);
+                       _numPoints = _dataPoints.length;
+                       _scaled = false;
+                       _broker.informSubscribers();
+               }
+               return numDeleted;
+       }
+
+
+       /**
+        * Halve the track by deleting alternate points
+        * @return number of points deleted
+        */
+       public int halve()
+       {
+               if (_numPoints < 100) return 0;
+               int newSize = _numPoints / 2;
+               int numDeleted = _numPoints - newSize;
+               DataPoint[] newPointArray = new DataPoint[newSize];
+               // Delete alternate points
+               for (int i=0; i<newSize; i++)
+                       newPointArray[i] = _dataPoints[i*2];
+               // Copy array references
+               _dataPoints = newPointArray;
+               _numPoints = _dataPoints.length;
+               _scaled = false;
+               _broker.informSubscribers();
+               return numDeleted;
+       }
+
+
+       /**
+        * Delete the specified point
+        * @return true if successful
+        */
+       public boolean deletePoint(int inIndex)
+       {
+               boolean answer = deleteRange(inIndex, inIndex);
+               return answer;
+       }
+
+
+       /**
+        * Delete the specified range of points from the Track
+        * @param inStart start of range (inclusive)
+        * @param inEnd end of range (inclusive)
+        * @return true if successful
+        */
+       public boolean deleteRange(int inStart, int inEnd)
+       {
+               if (inStart < 0 || inEnd < 0 || inEnd < inStart)
+               {
+                       // no valid range selected so can't delete
+                       return false;
+               }
+               // valid range, let's delete it
+               int numToDelete = inEnd - inStart + 1;
+               DataPoint[] newPointArray = new DataPoint[_numPoints - numToDelete];
+               // Copy points before the selected range
+               if (inStart > 0)
+               {
+                       System.arraycopy(_dataPoints, 0, newPointArray, 0, inStart);
+               }
+               // Copy points after the deleted one(s)
+               if (inEnd < (_numPoints - 1))
+               {
+                       System.arraycopy(_dataPoints, inEnd + 1, newPointArray, inStart,
+                               _numPoints - inEnd - 1);
+               }
+               // Copy points over original array (careful!)
+               _dataPoints = newPointArray;
+               _numPoints -= numToDelete;
+               // needs to be scaled again
+               _scaled = false;
+               return true;
+       }
+
+
+       /**
+        * Delete all the duplicate points in the track
+        * @return number of points deleted
+        */
+       public int deleteDuplicates()
+       {
+               // loop through Track counting duplicates first
+               boolean[] dupes = new boolean[_numPoints];
+               int numDupes = 0;
+               int i, j;
+               for (i=1; i<_numPoints; i++)
+               {
+                       DataPoint p1 = _dataPoints[i];
+                       // Loop through all points before this one
+                       for (j=0; j<i && !dupes[i]; j++)
+                       {
+                               DataPoint p2 = _dataPoints[j];
+                               if (p1.isDuplicate(p2))
+                               {
+                                       dupes[i] = true;
+                                       numDupes++;
+                               }
+                       }
+               }
+               if (numDupes > 0)
+               {
+                       // Make new resized array and copy DataPoints over
+                       DataPoint[] newPointArray = new DataPoint[_numPoints - numDupes];
+                       j = 0;
+                       for (i=0; i<_numPoints; i++)
+                       {
+                               if (!dupes[i])
+                               {
+                                       newPointArray[j] = _dataPoints[i];
+                                       j++;
+                               }
+                       }
+                       // Copy array references
+                       _dataPoints = newPointArray;
+                       _numPoints = _dataPoints.length;
+                       _scaled = false;
+                       _broker.informSubscribers();
+               }
+               return numDupes;
+       }
+
+
+       /**
+        * Reverse the specified range of points
+        * @return true if successful, false otherwise
+        */
+       public boolean reverseRange(int inStart, int inEnd)
+       {
+               if (inStart < 0 || inEnd < 0 || inStart >= inEnd || inEnd >= _numPoints)
+               {
+                       return false;
+               }
+               // calculate how many point swaps are required
+               int numPointsToReverse = (inEnd - inStart + 1) / 2;
+               DataPoint p = null;
+               for (int i=0; i<numPointsToReverse; i++)
+               {
+                       // swap pairs of points
+                       p = _dataPoints[inStart + i];
+                       _dataPoints[inStart + i] = _dataPoints[inEnd - i];
+                       _dataPoints[inEnd - i] = p;
+               }
+               // needs to be scaled again
+               _scaled = false;
+               _broker.informSubscribers();
+               return true;
+       }
+
+
+       /**
+        * Collect all waypoints to the start or end of the track
+        * @param inAtStart true to collect at start, false for end
+        * @return true if successful, false if no change
+        */
+       public boolean collectWaypoints(boolean inAtStart)
+       {
+               // Check for mixed data, numbers of waypoints & nons
+               int numWaypoints = 0, numNonWaypoints = 0;
+               boolean wayAfterNon = false, nonAfterWay = false;
+               DataPoint[] waypoints = new DataPoint[_numPoints];
+               DataPoint[] nonWaypoints = new DataPoint[_numPoints];
+               DataPoint point = null;
+               for (int i=0; i<_numPoints; i++)
+               {
+                       point = _dataPoints[i];
+                       if (point.isWaypoint())
+                       {
+                               waypoints[numWaypoints] = point;
+                               numWaypoints++;
+                               wayAfterNon |= (numNonWaypoints > 0);
+                       }
+                       else
+                       {
+                               nonWaypoints[numNonWaypoints] = point;
+                               numNonWaypoints++;
+                               nonAfterWay |= (numWaypoints > 0);
+                       }
+               }
+               // Exit if the data is already in the specified order
+               if (numWaypoints == 0 || numNonWaypoints == 0
+                       || (inAtStart && !wayAfterNon && nonAfterWay)
+                       || (!inAtStart && wayAfterNon && !nonAfterWay))
+               {
+                       return false;
+               }
+
+               // Copy the arrays back into _dataPoints in the specified order
+               if (inAtStart)
+               {
+                       System.arraycopy(waypoints, 0, _dataPoints, 0, numWaypoints);
+                       System.arraycopy(nonWaypoints, 0, _dataPoints, numWaypoints, numNonWaypoints);
+               }
+               else
+               {
+                       System.arraycopy(nonWaypoints, 0, _dataPoints, 0, numNonWaypoints);
+                       System.arraycopy(waypoints, 0, _dataPoints, numNonWaypoints, numWaypoints);
+               }
+               // needs to be scaled again
+               _scaled = false;
+               _broker.informSubscribers();
+               return true;
+       }
+
+
+       /**
+        * Interleave all waypoints by each nearest track point
+        * @return true if successful, false if no change
+        */
+       public boolean interleaveWaypoints()
+       {
+               // Separate waypoints and find nearest track point
+               int numWaypoints = 0;
+               DataPoint[] waypoints = new DataPoint[_numPoints];
+               int[] pointIndices = new int[_numPoints];
+               DataPoint point = null;
+               int i = 0;
+               for (i=0; i<_numPoints; i++)
+               {
+                       point = _dataPoints[i];
+                       if (point.isWaypoint())
+                       {
+                               waypoints[numWaypoints] = point;
+                               pointIndices[numWaypoints] = getNearestPointIndex(
+                                       _xValues[i], _yValues[i], -1.0, true);
+                               numWaypoints++;
+                       }
+               }
+               // Exit if data not mixed
+               if (numWaypoints == 0 || numWaypoints == _numPoints)
+                       return false;
+
+               // Loop round points copying to correct order
+               DataPoint[] dataCopy = new DataPoint[_numPoints];
+               int copyIndex = 0;
+               for (i=0; i<_numPoints; i++)
+               {
+                       point = _dataPoints[i];
+                       // if it's a track point, copy it
+                       if (!point.isWaypoint())
+                       {
+                               dataCopy[copyIndex] = point;
+                               copyIndex++;
+                       }
+                       // check for waypoints with this index
+                       for (int j=0; j<numWaypoints; j++)
+                       {
+                               if (pointIndices[j] == i)
+                               {
+                                       dataCopy[copyIndex] = waypoints[j];
+                                       copyIndex++;
+                               }
+                       }
+               }
+               // Copy data back to track
+               _dataPoints = dataCopy;
+               // needs to be scaled again to recalc x, y
+               _scaled = false;
+               _broker.informSubscribers();
+               return true;
+       }
+
+
+       /**
+        * Interpolate extra points between two selected ones
+        * @param inStartIndex start index of interpolation
+        * @param inNumPoints num points to insert
+        * @return true if successful
+        */
+       public boolean interpolate(int inStartIndex, int inNumPoints)
+       {
+               // check parameters
+               if (inStartIndex < 0 || inStartIndex >= _numPoints || inNumPoints <= 0)
+                       return false;
+
+               // get start and end points
+               DataPoint startPoint = getPoint(inStartIndex);
+               DataPoint endPoint = getPoint(inStartIndex + 1);
+
+               // Make array of points to insert
+               DataPoint[] insertedPoints = startPoint.interpolate(endPoint, inNumPoints);
+               
+               // Insert points into track
+               insertRange(insertedPoints, inStartIndex + 1);
+               return true;
+       }
+
+
+       //////// information methods /////////////
+
+
+       /**
+        * Get the point at the given index
+        * @param inPointNum index number, starting at 0
+        * @return DataPoint object, or null if out of range
+        */
+       public DataPoint getPoint(int inPointNum)
+       {
+               if (inPointNum > -1 && inPointNum < getNumPoints())
+               {
+                       return _dataPoints[inPointNum];
+               }
+               return null;
+       }
+
+
+       /**
+        * @return altitude range of points as AltitudeRange object
+        */
+       public AltitudeRange getAltitudeRange()
+       {
+               if (!_scaled) scalePoints();
+               return _altitudeRange;
+       }
+       /**
+        * @return the number of (valid) points in the track
+        */
+       public int getNumPoints()
+       {
+               return _numPoints;
+       }
+
+       /**
+        * @return The range of x values as a DoubleRange object
+        */
+       public DoubleRange getXRange()
+       {
+               if (!_scaled) scalePoints();
+               return _xRange;
+       }
+
+       /**
+        * @return The range of y values as a DoubleRange object
+        */
+       public DoubleRange getYRange()
+       {
+               if (!_scaled) scalePoints();
+               return _yRange;
+       }
+
+       /**
+        * @param inPointNum point index, starting at 0
+        * @return scaled x value of specified point
+        */
+       public double getX(int inPointNum)
+       {
+               if (!_scaled) scalePoints();
+               return _xValues[inPointNum];
+       }
+
+       /**
+        * @param inPointNum point index, starting at 0
+        * @return scaled y value of specified point
+        */
+       public double getY(int inPointNum)
+       {
+               if (!_scaled) scalePoints();
+               return _yValues[inPointNum];
+       }
+
+       /**
+        * @return the master field list
+        */
+       public FieldList getFieldList()
+       {
+               return _masterFieldList;
+       }
+
+
+       /**
+        * Checks if any data exists for the specified field
+        * @param inField Field to examine
+        * @return true if data exists for this field
+        */
+       public boolean hasData(Field inField)
+       {
+               return hasData(inField, 0, _numPoints-1);
+       }
+
+
+       /**
+        * Checks if any data exists for the specified field in the specified range
+        * @param inField Field to examine
+        * @param inStart start of range to check
+        * @param inEnd end of range to check (inclusive)
+        * @return true if data exists for this field
+        */
+       public boolean hasData(Field inField, int inStart, int inEnd)
+       {
+               for (int i=inStart; i<=inEnd; i++)
+               {
+                       if (_dataPoints[i].getFieldValue(inField) != null)
+                       {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
+
+       /**
+        * @return true if track contains waypoints and trackpoints
+        */
+       public boolean hasMixedData()
+       {
+               if (!_scaled) scalePoints();
+               return _mixedData;
+       }
+
+
+       ///////// Internal processing methods ////////////////
+
+
+       /**
+        * Scale all the points in the track to gain x and y values
+        * ready for plotting
+        */
+       private void scalePoints()
+       {
+               // Loop through all points in track, to see limits of lat, long and altitude
+               _longRange = new DoubleRange();
+               _latRange = new DoubleRange();
+               _altitudeRange = new AltitudeRange();
+               int p;
+               boolean hasWaypoint = false, hasTrackpoint = false;
+               for (p=0; p < getNumPoints(); p++)
+               {
+                       DataPoint point = getPoint(p);
+                       if (point != null && point.isValid())
+                       {
+                               _longRange.addValue(point.getLongitude().getDouble());
+                               _latRange.addValue(point.getLatitude().getDouble());
+                               if (point.getAltitude().isValid())
+                                       _altitudeRange.addValue(point.getAltitude());
+                               if (point.isWaypoint())
+                                       hasWaypoint = true;
+                               else
+                                       hasTrackpoint = true;
+                       }
+               }
+               _mixedData = hasWaypoint && hasTrackpoint;
+
+               // Use medians to centre at 0
+               double longMedian = (_longRange.getMaximum() + _longRange.getMinimum()) / 2.0;
+               double latMedian = (_latRange.getMaximum() + _latRange.getMinimum()) / 2.0;
+               double longFactor = Math.cos(latMedian / 180.0 * Math.PI); // Function of median latitude
+
+               // Loop over points and calculate scales
+               _xValues = new double[getNumPoints()];
+               _yValues = new double[getNumPoints()];
+               _xRange = new DoubleRange();
+               _yRange = new DoubleRange();
+               for (p=0; p < getNumPoints(); p++)
+               {
+                       DataPoint point = getPoint(p);
+                       if (point != null)
+                       {
+                               _xValues[p] = (point.getLongitude().getDouble() - longMedian) * longFactor;
+                               _xRange.addValue(_xValues[p]);
+                               _yValues[p] = (point.getLatitude().getDouble() - latMedian);
+                               _yRange.addValue(_yValues[p]);
+                       }
+               }
+               _scaled = true;
+       }
+
+
+       /**
+        * Find the nearest point to the specified x and y coordinates
+        * or -1 if no point is within the specified max distance
+        * @param inX x coordinate
+        * @param inY y coordinate
+        * @param inMaxDist maximum distance from selected coordinates
+        * @param inJustTrackPoints true if waypoints should be ignored
+        * @return index of nearest point or -1 if not found
+        */
+       public int getNearestPointIndex(double inX, double inY, double inMaxDist, boolean inJustTrackPoints)
+       {
+               int nearestPoint = 0;
+               double nearestDist = -1.0;
+               double currDist;
+               for (int i=0; i < getNumPoints(); i++)
+               {
+                       if (!inJustTrackPoints || !_dataPoints[i].isWaypoint())
+                       {
+                               currDist = Math.abs(_xValues[i] - inX) + Math.abs(_yValues[i] - inY);
+                               if (currDist < nearestDist || nearestDist < 0.0)
+                               {
+                                       nearestPoint = i;
+                                       nearestDist = currDist;
+                               }
+                       }
+               }
+               // Check whether it's within required distance
+               if (nearestDist > inMaxDist && inMaxDist > 0.0)
+               {
+                       return -1;
+               }
+               return nearestPoint;
+       }
+
+
+       ////////////////// Cloning and replacing ///////////////////
+
+       /**
+        * Clone the array of DataPoints
+        * @return shallow copy of DataPoint objects
+        */
+       public DataPoint[] cloneContents()
+       {
+               DataPoint[] clone = new DataPoint[getNumPoints()];
+               System.arraycopy(_dataPoints, 0, clone, 0, getNumPoints());
+               return clone;
+       }
+
+
+       /**
+        * Clone the specified range of data points
+        * @param inStart start index (inclusive)
+        * @param inEnd end index (inclusive)
+        * @return shallow copy of DataPoint objects
+        */
+       public DataPoint[] cloneRange(int inStart, int inEnd)
+       {
+               int numSelected = 0;
+               if (inEnd >= 0 && inEnd >= inStart)
+               {
+                       numSelected = inEnd - inStart + 1;
+               }
+               DataPoint[] result = new DataPoint[numSelected>0?numSelected:0];
+               if (numSelected > 0)
+               {
+                       System.arraycopy(_dataPoints, inStart, result, 0, numSelected);
+               }
+               return result;
+       }
+
+
+       /**
+        * Re-insert the specified point at the given index
+        * @param inPoint point to insert
+        * @param inIndex index at which to insert the point
+        * @return true if it worked, false otherwise
+        */
+       public boolean insertPoint(DataPoint inPoint, int inIndex)
+       {
+               if (inIndex > _numPoints || inPoint == null)
+               {
+                       return false;
+               }
+               // Make new array to copy points over to
+               DataPoint[] newPointArray = new DataPoint[_numPoints + 1];
+               if (inIndex > 0)
+               {
+                       System.arraycopy(_dataPoints, 0, newPointArray, 0, inIndex);
+               }
+               newPointArray[inIndex] = inPoint;
+               if (inIndex < _numPoints)
+               {
+                       System.arraycopy(_dataPoints, inIndex, newPointArray, inIndex+1, _numPoints - inIndex);
+               }
+               // Change over to new array
+               _dataPoints = newPointArray;
+               _numPoints++;
+               // needs to be scaled again
+               _scaled = false;
+               _broker.informSubscribers();
+               return true;
+       }
+
+
+       /**
+        * Re-insert the specified point range at the given index
+        * @param inPoints point array to insert
+        * @param inIndex index at which to insert the points
+        * @return true if it worked, false otherwise
+        */
+       public boolean insertRange(DataPoint[] inPoints, int inIndex)
+       {
+               if (inIndex > _numPoints || inPoints == null)
+               {
+                       return false;
+               }
+               // Make new array to copy points over to
+               DataPoint[] newPointArray = new DataPoint[_numPoints + inPoints.length];
+               if (inIndex > 0)
+               {
+                       System.arraycopy(_dataPoints, 0, newPointArray, 0, inIndex);
+               }
+               System.arraycopy(inPoints, 0, newPointArray, inIndex, inPoints.length);
+               if (inIndex < _numPoints)
+               {
+                       System.arraycopy(_dataPoints, inIndex, newPointArray, inIndex+inPoints.length, _numPoints - inIndex);
+               }
+               // Change over to new array
+               _dataPoints = newPointArray;
+               _numPoints += inPoints.length;
+               // needs to be scaled again
+               _scaled = false;
+               _broker.informSubscribers();
+               return true;
+       }
+
+
+       /**
+        * Replace the track contents with the given point array
+        * @param inContents array of DataPoint objects
+        */
+       public boolean replaceContents(DataPoint[] inContents)
+       {
+               // master field array stays the same
+               // (would need to store field array too if we wanted to redo a load)
+               // replace data array
+               _dataPoints = inContents;
+               _numPoints = _dataPoints.length;
+               _scaled = false;
+               _broker.informSubscribers();
+               return true;
+       }
+}
diff --git a/tim/prune/data/TrackInfo.java b/tim/prune/data/TrackInfo.java
new file mode 100644 (file)
index 0000000..71363da
--- /dev/null
@@ -0,0 +1,162 @@
+package tim.prune.data;
+
+import tim.prune.UpdateMessageBroker;
+
+/**
+ * Class to hold all track information, including data
+ * and the selection information
+ */
+public class TrackInfo
+{
+       private UpdateMessageBroker _broker = null;
+       private Track _track = null;
+       private Selection _selection = null;
+       private FileInfo _fileInfo = null;
+
+       /**
+        * Constructor
+        * @param inTrack Track object
+        * @param inBroker broker object
+        */
+       public TrackInfo(Track inTrack, UpdateMessageBroker inBroker)
+       {
+               _broker = inBroker;
+               _track = inTrack;
+               _selection = new Selection(_track, inBroker);
+               _fileInfo = new FileInfo();
+       }
+
+
+       /**
+        * @return the Track object
+        */
+       public Track getTrack()
+       {
+               return _track;
+       }
+
+
+       /**
+        * @return the Selection object
+        */
+       public Selection getSelection()
+       {
+               return _selection;
+       }
+
+
+       /**
+        * @return the FileInfo object
+        */
+       public FileInfo getFileInfo()
+       {
+               return _fileInfo;
+       }
+
+       /**
+        * Get the currently selected point, if any
+        * @return DataPoint if single point selected, otherwise null
+        */
+       public DataPoint getCurrentPoint()
+       {
+               return _track.getPoint(_selection.getCurrentPointIndex());
+       }
+
+
+       /**
+        * Load the specified data into the Track
+        * @param inFieldArray array of Field objects describing fields
+        * @param inPointArray 2d object array containing data
+        * @param inAltFormat altitude format
+        */
+       public void loadTrack(Field[] inFieldArray, Object[][] inPointArray, int inAltFormat)
+       {
+               _track.cropTo(0);
+               _track.load(inFieldArray, inPointArray, inAltFormat);
+               _selection.clearAll();
+       }
+
+
+       /**
+        * Delete the currently selected range of points
+        * @return true if successful
+        */
+       public boolean deleteRange()
+       {
+               int currPoint = _selection.getCurrentPointIndex();
+               int startSel = _selection.getStart();
+               int endSel = _selection.getEnd();
+               boolean answer = _track.deleteRange(startSel, endSel);
+               // clear range selection
+               _selection.modifyRangeDeleted();
+               return answer;
+       }
+
+
+       /**
+        * Delete the currently selected point
+        * @return true if point deleted
+        */
+       public boolean deletePoint()
+       {
+               if (_track.deletePoint(_selection.getCurrentPointIndex()))
+               {
+                       _selection.modifyPointDeleted();
+                       _broker.informSubscribers();
+                       return true;
+               }
+               return false;
+       }
+
+
+       /**
+        * Compress the track to the given resolution
+        * @param inResolution resolution
+        * @return number of points deleted
+        */
+       public int compress(int inResolution)
+       {
+               int numDeleted = _track.compress(inResolution);
+               if (numDeleted > 0)
+                       _selection.clearAll();
+               return numDeleted;
+       }
+
+
+       /**
+        * Delete all the duplicate points in the track
+        * @return number of points deleted
+        */
+       public int deleteDuplicates()
+       {
+               int numDeleted = _track.deleteDuplicates();
+               if (numDeleted > 0)
+                       _selection.clearAll();
+               return numDeleted;
+       }
+
+
+       /**
+        * Clone the selected range of data points
+        * @return shallow copy of DataPoint objects
+        */
+       public DataPoint[] cloneSelectedRange()
+       {
+               return _track.cloneRange(_selection.getStart(), _selection.getEnd());
+       }
+
+
+       /**
+        * Interpolate extra points between two selected ones
+        * @param inStartIndex start index of interpolation
+        * @param inNumPoints num points to insert
+        * @return true if successful
+        */
+       public boolean interpolate(int inNumPoints)
+       {
+               boolean success = _track.interpolate(_selection.getStart(), inNumPoints);
+               if (success)
+                       _selection.selectRangeEnd(_selection.getEnd() + inNumPoints);
+               return success;
+       }
+}
diff --git a/tim/prune/gui/AboutScreen.java b/tim/prune/gui/AboutScreen.java
new file mode 100644 (file)
index 0000000..8e5c1da
--- /dev/null
@@ -0,0 +1,90 @@
+package tim.prune.gui;
+
+import java.awt.Component;
+import java.awt.Font;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+
+import javax.swing.BorderFactory;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JEditorPane;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+
+import tim.prune.GpsPruner;
+import tim.prune.I18nManager;
+
+/**
+ * Class to represent the "About" popup window
+ */
+public class AboutScreen extends JDialog
+{
+
+       /**
+        * Constructor
+        */
+       public AboutScreen(JFrame inParent)
+       {
+               super(inParent, I18nManager.getText("dialog.about.title"));
+               getContentPane().add(makeContents());
+       }
+
+
+       /**
+        * @return the contents of the window as a Component
+        */
+       private Component makeContents()
+       {
+               JPanel mainPanel = new JPanel();
+               mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
+               mainPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+               JLabel titleLabel = new JLabel("Prune");
+               titleLabel.setFont(new Font("SansSerif", Font.BOLD, 24));
+               titleLabel.setAlignmentX(JLabel.CENTER_ALIGNMENT);
+               mainPanel.add(titleLabel);
+               JLabel versionLabel = new JLabel(I18nManager.getText("dialog.about.version") + ": " + GpsPruner.VERSION_NUMBER);
+               versionLabel.setAlignmentX(JLabel.CENTER_ALIGNMENT);
+               mainPanel.add(versionLabel);
+               JLabel buildLabel = new JLabel(I18nManager.getText("dialog.about.build") + ": " + GpsPruner.BUILD_NUMBER);
+               buildLabel.setAlignmentX(JLabel.CENTER_ALIGNMENT);
+               mainPanel.add(buildLabel);
+               mainPanel.add(new JLabel(" "));
+               StringBuffer descBuffer = new StringBuffer();
+               descBuffer.append("<p>").append(I18nManager.getText("dialog.about.summarytext1")).append("</p>");
+               descBuffer.append("<p>").append(I18nManager.getText("dialog.about.summarytext2")).append("</p>");
+               descBuffer.append("<p>").append(I18nManager.getText("dialog.about.summarytext3")).append("</p>");
+               JEditorPane descPane = new JEditorPane("text/html", descBuffer.toString());
+               descPane.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+               descPane.setEditable(false);
+               descPane.setOpaque(false);
+               descPane.setAlignmentX(JEditorPane.CENTER_ALIGNMENT);
+               // descPane.setBackground(Color.GRAY);
+               mainPanel.add(descPane);
+               mainPanel.add(new JLabel(" "));
+               JButton okButton = new JButton(I18nManager.getText("button.ok"));
+               okButton.addActionListener(new ActionListener()
+               {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               dispose();
+                       }
+               });
+               okButton.setAlignmentX(JButton.CENTER_ALIGNMENT);
+               mainPanel.add(okButton);
+               return mainPanel;
+       }
+
+
+       /**
+        * Show window
+        */
+       public void show()
+       {
+               pack();
+               // setSize(300,200);
+               super.show();
+       }
+}
diff --git a/tim/prune/gui/DetailsDisplay.java b/tim/prune/gui/DetailsDisplay.java
new file mode 100644 (file)
index 0000000..e9ed747
--- /dev/null
@@ -0,0 +1,449 @@
+package tim.prune.gui;
+
+import java.awt.BorderLayout;
+import java.awt.Component;
+import java.awt.FlowLayout;
+import java.awt.Font;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.AdjustmentEvent;
+import java.awt.event.AdjustmentListener;
+import java.text.NumberFormat;
+
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.JButton;
+import javax.swing.JComboBox;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JScrollBar;
+import javax.swing.border.EtchedBorder;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.Altitude;
+import tim.prune.data.Coordinate;
+import tim.prune.data.DataPoint;
+import tim.prune.data.Distance;
+import tim.prune.data.Field;
+import tim.prune.data.IntegerRange;
+import tim.prune.data.Selection;
+import tim.prune.data.TrackInfo;
+
+/**
+ * Class to hold point details and selection details
+ * as a visual component
+ */
+public class DetailsDisplay extends GenericDisplay
+{
+       // App object to be notified of editing commands
+       private App _app = null;
+
+       // Track details
+       private JLabel _trackpointsLabel = null;
+       private JLabel _filenameLabel = null;
+       // Point details
+       private JLabel _indexLabel = null;
+       private JLabel _latLabel = null, _longLabel = null;
+       private JLabel _altLabel = null, _nameLabel = null;
+       private JLabel _timeLabel = null;
+       // Scroll bar
+       private JScrollBar _scroller = null;
+       private boolean _ignoreScrollEvents = false;
+       // Button panel
+       private JButton _startRangeButton = null, _endRangeButton = null;
+       private JButton _deletePointButton = null, _deleteRangeButton = null;
+
+       // Range details
+       private JLabel _rangeLabel = null;
+       private JLabel _distanceLabel = null, _durationLabel = null;
+       private JLabel _altRangeLabel = null, _updownLabel = null;
+       // Units
+       private JComboBox _unitsDropdown = null;
+       // Formatter
+       private NumberFormat _distanceFormatter = NumberFormat.getInstance();
+
+       // Cached labels
+       private static final String LABEL_POINT_SELECTED1 = I18nManager.getText("details.index.selected") + ": ";
+       private static final String LABEL_POINT_LATITUDE = I18nManager.getText("fieldname.latitude") + ": ";
+       private static final String LABEL_POINT_LONGITUDE = I18nManager.getText("fieldname.longitude") + ": ";
+       private static final String LABEL_POINT_ALTITUDE = I18nManager.getText("fieldname.altitude") + ": ";
+       private static final String LABEL_POINT_TIMESTAMP = I18nManager.getText("fieldname.timestamp") + ": ";
+       private static final String LABEL_POINT_WAYPOINTNAME = I18nManager.getText("fieldname.waypointname") + ": ";
+       private static final String LABEL_RANGE_SELECTED1 = I18nManager.getText("details.range.selected") + ": ";
+       private static final String LABEL_RANGE_DURATION = I18nManager.getText("fieldname.duration") + ": ";
+       private static final String LABEL_RANGE_DISTANCE = I18nManager.getText("fieldname.distance") + ": ";
+       private static final String LABEL_RANGE_ALTITUDE = I18nManager.getText("fieldname.altitude") + ": ";
+       private static final String LABEL_RANGE_CLIMB = I18nManager.getText("details.range.climb") + ": ";
+       private static final String LABEL_RANGE_DESCENT = ", " + I18nManager.getText("details.range.descent") + ": ";
+       private static String LABEL_POINT_ALTITUDE_UNITS = null;
+       private static int LABEL_POINT_ALTITUDE_FORMAT = Altitude.FORMAT_NONE;
+       // scrollbar interval
+       private static final int SCROLLBAR_INTERVAL = 50;
+
+
+       /**
+        * Constructor
+        * @param inApp App object for callbacks
+        * @param inTrackInfo Track info object
+        */
+       public DetailsDisplay(App inApp, TrackInfo inTrackInfo)
+       {
+               super(inTrackInfo);
+               _app = inApp;
+               setLayout(new BorderLayout());
+
+               JPanel mainPanel = new JPanel();
+               mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
+               mainPanel.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
+               // Track details panel
+               JPanel trackDetailsPanel = new JPanel();
+               trackDetailsPanel.setLayout(new BoxLayout(trackDetailsPanel, BoxLayout.Y_AXIS));
+               trackDetailsPanel.setBorder(BorderFactory.createCompoundBorder(
+                       BorderFactory.createEtchedBorder(EtchedBorder.LOWERED), BorderFactory.createEmptyBorder(3, 3, 3, 3))
+               );
+               JLabel trackDetailsLabel = new JLabel(I18nManager.getText("details.trackdetails"));
+               Font biggerFont = trackDetailsLabel.getFont();
+               biggerFont = biggerFont.deriveFont(Font.BOLD, biggerFont.getSize2D() + 2.0f);
+               trackDetailsLabel.setFont(biggerFont);
+               trackDetailsPanel.add(trackDetailsLabel);
+               _trackpointsLabel = new JLabel(I18nManager.getText("details.notrack"));
+               trackDetailsPanel.add(_trackpointsLabel);
+               _filenameLabel = new JLabel("");
+               trackDetailsPanel.add(_filenameLabel);
+               
+               // Point details panel
+               JPanel pointDetailsPanel = new JPanel();
+               pointDetailsPanel.setLayout(new BoxLayout(pointDetailsPanel, BoxLayout.Y_AXIS));
+               pointDetailsPanel.setBorder(BorderFactory.createCompoundBorder(
+                       BorderFactory.createEtchedBorder(EtchedBorder.LOWERED), BorderFactory.createEmptyBorder(3, 3, 3, 3))
+               );
+               JLabel pointDetailsLabel = new JLabel(I18nManager.getText("details.pointdetails"));
+               pointDetailsLabel.setFont(biggerFont);
+               pointDetailsPanel.add(pointDetailsLabel);
+               _indexLabel = new JLabel(I18nManager.getText("details.nopointselection"));
+               pointDetailsPanel.add(_indexLabel);
+               _latLabel = new JLabel("");
+               pointDetailsPanel.add(_latLabel);
+               _longLabel = new JLabel("");
+               pointDetailsPanel.add(_longLabel);
+               _altLabel = new JLabel("");
+               pointDetailsPanel.add(_altLabel);
+               _timeLabel = new JLabel("");
+               pointDetailsPanel.add(_timeLabel);
+               _nameLabel = new JLabel("");
+               pointDetailsPanel.add(_nameLabel);
+               pointDetailsPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
+
+               // Scroll bar
+               _scroller = new JScrollBar(JScrollBar.HORIZONTAL, 0, SCROLLBAR_INTERVAL, 0, 100);
+               _scroller.addAdjustmentListener(new AdjustmentListener() {
+                       public void adjustmentValueChanged(AdjustmentEvent e)
+                       {
+                               selectPoint(e.getValue());
+                       }
+               });
+               _scroller.setEnabled(false);
+
+               // Button panel
+               JPanel buttonPanel = new JPanel();
+               buttonPanel.setLayout(new GridLayout(2, 2, 3, 3));
+               _startRangeButton = new JButton(I18nManager.getText("button.startrange"));
+               _startRangeButton.addActionListener(new ActionListener()
+                       {
+                               public void actionPerformed(ActionEvent e)
+                               {
+                                       _trackInfo.getSelection().selectRangeStart();
+                               }
+                       });
+               _startRangeButton.setEnabled(false);
+               buttonPanel.add(_startRangeButton);
+               _endRangeButton = new JButton(I18nManager.getText("button.endrange"));
+               _endRangeButton.addActionListener(new ActionListener()
+                       {
+                               public void actionPerformed(ActionEvent e)
+                               {
+                                       _trackInfo.getSelection().selectRangeEnd();
+                               }
+                       });
+               _endRangeButton.setEnabled(false);
+               buttonPanel.add(_endRangeButton);
+               _deletePointButton = new JButton(I18nManager.getText("button.deletepoint"));
+               _deletePointButton.addActionListener(new ActionListener()
+                       {
+                               public void actionPerformed(ActionEvent e)
+                               {
+                                       _app.deleteCurrentPoint();
+                               }
+                       });
+               _deletePointButton.setEnabled(false);
+               buttonPanel.add(_deletePointButton);
+               _deleteRangeButton = new JButton(I18nManager.getText("button.deleterange"));
+               _deleteRangeButton.addActionListener(new ActionListener()
+                       {
+                               public void actionPerformed(ActionEvent e)
+                               {
+                                       _app.deleteSelectedRange();
+                               }
+                       });
+               _deleteRangeButton.setEnabled(false);
+               buttonPanel.add(_deleteRangeButton);
+               buttonPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
+
+               // range details panel
+               JPanel otherDetailsPanel = new JPanel();
+               otherDetailsPanel.setLayout(new BoxLayout(otherDetailsPanel, BoxLayout.Y_AXIS));
+               otherDetailsPanel.setBorder(BorderFactory.createCompoundBorder(
+                       BorderFactory.createEtchedBorder(EtchedBorder.LOWERED), BorderFactory.createEmptyBorder(3, 3, 3, 3))
+               );
+
+               JLabel otherDetailsLabel = new JLabel(I18nManager.getText("details.rangedetails"));
+               otherDetailsLabel.setFont(biggerFont);
+               otherDetailsPanel.add(otherDetailsLabel);
+               _rangeLabel = new JLabel(I18nManager.getText("details.norangeselection"));
+               otherDetailsPanel.add(_rangeLabel);
+               _distanceLabel = new JLabel("");
+               otherDetailsPanel.add(_distanceLabel);
+               _durationLabel = new JLabel("");
+               otherDetailsPanel.add(_durationLabel);
+               _altRangeLabel = new JLabel("");
+               otherDetailsPanel.add(_altRangeLabel);
+               _updownLabel = new JLabel("");
+               otherDetailsPanel.add(_updownLabel);
+               otherDetailsPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
+
+               // add the main panel at the top
+               add(mainPanel, BorderLayout.NORTH);
+               // add the slider, point details, and the other details to the main panel
+               mainPanel.add(buttonPanel);
+               mainPanel.add(Box.createVerticalStrut(5));
+               mainPanel.add(_scroller);
+               mainPanel.add(Box.createVerticalStrut(5));
+               mainPanel.add(trackDetailsPanel);
+               mainPanel.add(Box.createVerticalStrut(5));
+               mainPanel.add(pointDetailsPanel);
+               mainPanel.add(Box.createVerticalStrut(5));
+               mainPanel.add(otherDetailsPanel);
+
+               // Add units selection
+               JPanel lowerPanel = new JPanel();
+               lowerPanel.setLayout(new FlowLayout(FlowLayout.LEFT));
+               lowerPanel.add(new JLabel(I18nManager.getText("details.distanceunits") + ": "));
+               String[] distUnits = {I18nManager.getText("units.kilometres"), I18nManager.getText("units.miles")};
+               _unitsDropdown = new JComboBox(distUnits);
+               _unitsDropdown.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               dataUpdated();
+                       }
+               });
+               lowerPanel.add(_unitsDropdown);
+               add(lowerPanel, BorderLayout.SOUTH);
+       }
+
+
+       /**
+        * Select the specified point
+        * @param inValue value to select
+        */
+       private void selectPoint(int inValue)
+       {
+               if (_track != null && !_ignoreScrollEvents)
+               {
+                       _trackInfo.getSelection().selectPoint(inValue);
+               }
+       }
+
+
+       /**
+        * Notification that Track has been updated
+        */
+       public void dataUpdated()
+       {
+               // Update track data
+               if (_track == null || _track.getNumPoints() <= 0)
+               {
+                       _trackpointsLabel.setText(I18nManager.getText("details.notrack"));
+                       _filenameLabel.setText("");
+               }
+               else
+               {
+                       _trackpointsLabel.setText(I18nManager.getText("details.track.points") + ": "
+                               + _track.getNumPoints());
+                       int numFiles = _trackInfo.getFileInfo().getNumFiles();
+                       if (numFiles == 1)
+                       {
+                               _filenameLabel.setText(I18nManager.getText("details.track.file") + ": "
+                                       + _trackInfo.getFileInfo().getFilename());
+                       }
+                       else if (numFiles > 1)
+                       {
+                               _filenameLabel.setText(I18nManager.getText("details.track.numfiles") + ": "
+                                       + numFiles);
+                       }
+                       else _filenameLabel.setText("");
+               }
+
+               // Update current point data, if any
+               DataPoint currentPoint = _trackInfo.getCurrentPoint();
+               Selection selection = _trackInfo.getSelection();
+               int currentPointIndex = selection.getCurrentPointIndex();
+               if (_track == null || currentPoint == null)
+               {
+                       _indexLabel.setText(I18nManager.getText("details.nopointselection"));
+                       _latLabel.setText("");
+                       _longLabel.setText("");
+                       _altLabel.setText("");
+                       _timeLabel.setText("");
+                       _nameLabel.setText("");
+               }
+               else
+               {
+                       _indexLabel.setText(LABEL_POINT_SELECTED1
+                               + (currentPointIndex+1) + " " + I18nManager.getText("details.index.of")
+                               + " " + _track.getNumPoints());
+                       _latLabel.setText(LABEL_POINT_LATITUDE + currentPoint.getLatitude().output(Coordinate.FORMAT_NONE));
+                       _longLabel.setText(LABEL_POINT_LONGITUDE + currentPoint.getLongitude().output(Coordinate.FORMAT_NONE));
+                       _altLabel.setText(LABEL_POINT_ALTITUDE
+                               + (currentPoint.hasAltitude()?
+                                       (currentPoint.getAltitude().getValue() + getAltitudeUnitsLabel(currentPoint.getAltitude().getFormat())):
+                               ""));
+                       if (currentPoint.getTimestamp().isValid())
+                               _timeLabel.setText(LABEL_POINT_TIMESTAMP + currentPoint.getTimestamp().getText());
+                       else
+                               _timeLabel.setText("");
+                       String name = currentPoint.getFieldValue(Field.WAYPT_NAME);
+                       if (name != null && !name.equals(""))
+                       {
+                               _nameLabel.setText(LABEL_POINT_WAYPOINTNAME + name);
+                       }
+                       else _nameLabel.setText("");
+               }
+
+               // Update scroller settings
+               _ignoreScrollEvents = true;
+               if (_track == null || _track.getNumPoints() < 2)
+               {
+                       // careful to avoid event loops here
+                       // _scroller.setValue(0);
+                       _scroller.setEnabled(false);
+               }
+               else
+               {
+                       _scroller.setMaximum(_track.getNumPoints() + SCROLLBAR_INTERVAL);
+                       if (currentPointIndex >= 0)
+                               _scroller.setValue(currentPointIndex);
+                       _scroller.setEnabled(true);
+               }
+               _ignoreScrollEvents = false;
+
+               // Update button panel
+               boolean hasPoint = (_track != null && currentPointIndex >= 0);
+               _startRangeButton.setEnabled(hasPoint);
+               _endRangeButton.setEnabled(hasPoint);
+               _deletePointButton.setEnabled(hasPoint);
+               _deleteRangeButton.setEnabled(selection.hasRangeSelected());
+
+               // Update range details
+               if (_track == null || !selection.hasRangeSelected())
+               {
+                       _rangeLabel.setText(I18nManager.getText("details.norangeselection"));
+                       _distanceLabel.setText("");
+                       _durationLabel.setText("");
+                       _altRangeLabel.setText("");
+                       _updownLabel.setText("");
+               }
+               else
+               {
+                       _rangeLabel.setText(LABEL_RANGE_SELECTED1
+                               + (selection.getStart()+1) + " " + I18nManager.getText("details.range.to")
+                               + " " + (selection.getEnd()+1));
+                       if (_unitsDropdown.getSelectedIndex() == 0)
+                               _distanceLabel.setText(LABEL_RANGE_DISTANCE + buildDistanceString(
+                                       selection.getDistance(Distance.UNITS_KILOMETRES))
+                                       + " " + I18nManager.getText("units.kilometres.short"));
+                       else
+                               _distanceLabel.setText(LABEL_RANGE_DISTANCE + buildDistanceString(
+                                       selection.getDistance(Distance.UNITS_MILES))
+                                       + " " + I18nManager.getText("units.miles.short"));
+                       if (selection.getNumSeconds() > 0)
+                               _durationLabel.setText(LABEL_RANGE_DURATION + buildDurationString(selection.getNumSeconds()));
+                       else
+                               _durationLabel.setText("");
+                       String altUnitsLabel = getAltitudeUnitsLabel(selection.getAltitudeFormat());
+                       IntegerRange altRange = selection.getAltitudeRange();
+                       if (altRange.getMinimum() >= 0 && altRange.getMaximum() >= 0)
+                       {
+                               _altRangeLabel.setText(LABEL_RANGE_ALTITUDE
+                                       + altRange.getMinimum() + altUnitsLabel + " "
+                                       + I18nManager.getText("details.altitude.to") + " "
+                                       + altRange.getMaximum() + altUnitsLabel);
+                               _updownLabel.setText(LABEL_RANGE_CLIMB + selection.getClimb() + altUnitsLabel
+                                       + LABEL_RANGE_DESCENT + selection.getDescent() + altUnitsLabel);
+                       }
+                       else
+                       {
+                               _altRangeLabel.setText("");
+                               _updownLabel.setText("");
+                       }
+               }
+       }
+
+
+       /**
+        * Choose the appropriate altitude units label for the specified format
+        * @param inFormat altitude format
+        * @return language-sensitive string
+        */
+       private static String getAltitudeUnitsLabel(int inFormat)
+       {
+               if (inFormat == LABEL_POINT_ALTITUDE_FORMAT && LABEL_POINT_ALTITUDE_UNITS != null)
+                       return LABEL_POINT_ALTITUDE_UNITS;
+               LABEL_POINT_ALTITUDE_FORMAT = inFormat;
+               if (inFormat == Altitude.FORMAT_METRES)
+                       return " " + I18nManager.getText("units.metres.short");
+               return " " + I18nManager.getText("units.feet.short");
+       }
+
+
+       /**
+        * Build a String to describe a time duration
+        * @param inNumSecs number of seconds
+        * @return time as a string, days, hours, mins, secs as appropriate
+        */
+       private static String buildDurationString(long inNumSecs)
+       {
+               if (inNumSecs <= 0L) return "";
+               if (inNumSecs < 60L) return "" + inNumSecs + I18nManager.getText("display.range.time.secs");
+               if (inNumSecs < 3600L) return "" + (inNumSecs / 60) + I18nManager.getText("display.range.time.mins")
+                       + " " + (inNumSecs % 60) + I18nManager.getText("display.range.time.secs");
+               if (inNumSecs < 86400L) return "" + (inNumSecs / 60 / 60) + I18nManager.getText("display.range.time.hours")
+                       + " " + ((inNumSecs / 60) % 60) + I18nManager.getText("display.range.time.mins");
+               if (inNumSecs < 8640000L) return "" + (inNumSecs / 86400L) + I18nManager.getText("display.range.time.days");
+               return "big";
+       }
+
+
+       /**
+        * Build a String to describe a distance
+        * @param inDist distance
+        * @return formatted String
+        */
+       private String buildDistanceString(double inDist)
+       {
+               // Set precision of formatter
+               int numDigits = 0;
+               if (inDist < 1.0)
+                       numDigits = 3;
+               else if (inDist < 10.0)
+                       numDigits = 2;
+               else if (inDist < 100.0)
+                       numDigits = 1;
+               // set formatter
+               _distanceFormatter.setMaximumFractionDigits(numDigits);
+               _distanceFormatter.setMinimumFractionDigits(numDigits);
+               return _distanceFormatter.format(inDist);
+       }
+}
diff --git a/tim/prune/gui/GenericChart.java b/tim/prune/gui/GenericChart.java
new file mode 100644 (file)
index 0000000..081c823
--- /dev/null
@@ -0,0 +1,103 @@
+package tim.prune.gui;
+
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+
+import tim.prune.I18nManager;
+import tim.prune.data.TrackInfo;
+
+
+/**
+ * Generic chart component to form baseclass for map and profile charts
+ */
+public abstract class GenericChart extends GenericDisplay implements MouseListener
+{
+       protected Dimension MINIMUM_SIZE = new Dimension(200, 250);
+       protected static final int BORDER_WIDTH = 8;
+
+       // Colours
+       private static final Color COLOR_BORDER_BG   = Color.GRAY;
+       private static final Color COLOR_CHART_BG    = Color.WHITE;
+       private static final Color COLOR_CHART_LINE  = Color.BLACK;
+       private static final Color COLOR_NODATA_TEXT = Color.GRAY;
+
+
+       /**
+        * Constructor
+        * @param inTrackInfo track info object
+        */
+       protected GenericChart(TrackInfo inTrackInfo)
+       {
+               super(inTrackInfo);
+       }
+
+       /**
+        * Override minimum size method to restrict map
+        */
+       public Dimension getMinimumSize()
+       {
+               return MINIMUM_SIZE;
+       }
+
+       /**
+        * Override paint method to draw map
+        */
+       public void paint(Graphics g)
+       {
+               super.paint(g);
+               int width = getWidth();
+               int height = getHeight();
+               // border background
+               g.setColor(COLOR_BORDER_BG);
+               g.fillRect(0, 0, width, height);
+               if (width < 2*BORDER_WIDTH || height < 2*BORDER_WIDTH) return;
+               // blank graph area, with line border
+               g.setColor(COLOR_CHART_BG);
+               g.fillRect(BORDER_WIDTH, BORDER_WIDTH, width - 2*BORDER_WIDTH, height-2*BORDER_WIDTH);
+               g.setColor(COLOR_CHART_LINE);
+               g.drawRect(BORDER_WIDTH, BORDER_WIDTH, width - 2*BORDER_WIDTH, height-2*BORDER_WIDTH);
+               // Display message if no data to be displayed
+               if (_track == null || _track.getNumPoints() <= 0)
+               {
+                       g.setColor(COLOR_NODATA_TEXT);
+                       g.drawString(I18nManager.getText("display.nodata"), 50, height/2);
+               }
+       }
+
+
+       /**
+        * Method to inform map that data has changed
+        */
+       public void dataUpdated()
+       {
+               repaint();
+       }
+
+
+       /**
+        * mouse enter events ignored
+        */
+       public void mouseEntered(MouseEvent e)
+       {}
+
+       /**
+        * mouse exit events ignored
+        */
+       public void mouseExited(MouseEvent e)
+       {}
+
+       /**
+        * ignore mouse pressed for now too
+        */
+       public void mousePressed(MouseEvent e)
+       {}
+
+       /**
+        * and also ignore mouse released
+        */
+       public void mouseReleased(MouseEvent e)
+       {}
+}
diff --git a/tim/prune/gui/GenericDisplay.java b/tim/prune/gui/GenericDisplay.java
new file mode 100644 (file)
index 0000000..67c4d92
--- /dev/null
@@ -0,0 +1,26 @@
+package tim.prune.gui;
+
+import javax.swing.JPanel;
+
+import tim.prune.DataSubscriber;
+import tim.prune.data.Track;
+import tim.prune.data.TrackInfo;
+
+/**
+ * Superclass of all display components
+ */
+public abstract class GenericDisplay extends JPanel implements DataSubscriber
+{
+       protected TrackInfo _trackInfo = null;
+       protected Track _track = null;
+
+       /**
+        * Constructor
+        * @param inTrackInfo trackInfo object
+        */
+       public GenericDisplay(TrackInfo inTrackInfo)
+       {
+               _trackInfo = inTrackInfo;
+               _track = _trackInfo.getTrack();
+       }
+}
diff --git a/tim/prune/gui/MapChart.java b/tim/prune/gui/MapChart.java
new file mode 100644 (file)
index 0000000..13b1b7d
--- /dev/null
@@ -0,0 +1,581 @@
+package tim.prune.gui;
+
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.FontMetrics;
+import java.awt.Graphics;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.KeyEvent;
+import java.awt.event.KeyListener;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseMotionListener;
+import java.awt.event.MouseWheelEvent;
+import java.awt.event.MouseWheelListener;
+import java.awt.image.BufferedImage;
+
+import javax.swing.JCheckBoxMenuItem;
+import javax.swing.JMenuItem;
+import javax.swing.JPopupMenu;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.DataPoint;
+import tim.prune.data.Field;
+import tim.prune.data.TrackInfo;
+
+
+/**
+ * Display component for the main map
+ */
+public class MapChart extends GenericChart implements MouseWheelListener, KeyListener, MouseMotionListener
+{
+       // Constants
+       private static final int POINT_RADIUS = 4;
+       private static final int CLICK_SENSITIVITY = 10;
+       private static final double ZOOM_SCALE_FACTOR = 1.2;
+       private static final int PAN_DISTANCE = 10;
+       private static final int LIMIT_WAYPOINT_NAMES = 40;
+
+       // Colours
+       private static final Color COLOR_BG         = Color.WHITE;
+       private static final Color COLOR_POINT      = Color.BLUE;
+       private static final Color COLOR_CURR_RANGE = Color.GREEN;
+       private static final Color COLOR_CROSSHAIRS = Color.RED;
+       private static final Color COLOR_WAYPT_NAME = Color.BLACK;
+
+       // Instance variables
+       private App _app = null;
+       private BufferedImage _image = null;
+       private JPopupMenu _popup = null;
+       private JCheckBoxMenuItem _autoPanMenuItem = null;
+       private String _trackString = null;
+       private int _numPoints = -1;
+       private double _scale;
+       private double _offsetX, _offsetY, _zoomScale;
+       private int _lastSelectedPoint = -1;
+       private int _dragStartX = -1, _dragStartY = -1;
+       private int _zoomDragFromX = -1, _zoomDragFromY = -1;
+       private int _zoomDragToX = -1, _zoomDragToY = -1;
+       private boolean _zoomDragging = false;
+
+
+       /**
+        * Constructor
+        * @param inApp App object for callbacks
+        * @param inTrackInfo track info object
+        */
+       public MapChart(App inApp, TrackInfo inTrackInfo)
+       {
+               super(inTrackInfo);
+               _app = inApp;
+               makePopup();
+               addMouseListener(this);
+               addMouseWheelListener(this);
+               addMouseMotionListener(this);
+               setFocusable(true);
+               addKeyListener(this);
+               MINIMUM_SIZE = new Dimension(200, 250);
+               _zoomScale = 1.0;
+       }
+
+
+       /**
+        * Override track updating to refresh image
+        */
+       public void dataUpdated()
+       {
+               // Check if number of points has changed or Track
+               // object has a different signature
+               if (_track.getNumPoints() != _numPoints)
+               {
+                       _image = null;
+                       _numPoints = _track.getNumPoints();
+               }
+               super.dataUpdated();
+       }
+
+
+       /**
+        * Override paint method to draw map
+        */
+       public void paint(Graphics g)
+       {
+               if (_track == null)
+               {
+                       super.paint(g);
+                       return;
+               }
+
+               int width = getWidth();
+               int height = getHeight();
+               int x, y;
+
+               // Find x and y ranges, and scale to fit
+               double scaleX = (_track.getXRange().getMaximum() - _track.getXRange().getMinimum())
+                 / (width - 2 * (BORDER_WIDTH + POINT_RADIUS));
+               double scaleY = (_track.getYRange().getMaximum() - _track.getYRange().getMinimum())
+                 / (height - 2 * (BORDER_WIDTH + POINT_RADIUS));
+               _scale = scaleX;
+               if (scaleY > _scale) _scale = scaleY;
+
+               // Autopan if necessary
+               int selectedPoint = _trackInfo.getSelection().getCurrentPointIndex();
+               if (_autoPanMenuItem.isSelected() && selectedPoint >= 0 && selectedPoint != _lastSelectedPoint)
+               {
+                       // Autopan is enabled and a point is selected - work out x and y to see if it's within range
+                       x = width/2 + (int) ((_track.getX(selectedPoint) - _offsetX) / _scale * _zoomScale);
+                       y = height/2 - (int) ((_track.getY(selectedPoint) - _offsetY) / _scale * _zoomScale);
+                       if (x < BORDER_WIDTH)
+                       {
+                               // autopan left
+                               _offsetX -= (width / 4 - x) * _scale / _zoomScale;
+                               _image = null;
+                       }
+                       else if (x > (width - BORDER_WIDTH))
+                       {
+                               // autopan right
+                               _offsetX += (x - width * 3/4) * _scale / _zoomScale;
+                               _image = null;
+                       }
+                       if (y < BORDER_WIDTH)
+                       {
+                               // autopan up
+                               _offsetY += (height / 4 - y) * _scale / _zoomScale;
+                               _image = null;
+                       }
+                       else if (y > (height - BORDER_WIDTH))
+                       {
+                               // autopan down
+                               _offsetY -= (y - height * 3/4) * _scale / _zoomScale;
+                               _image = null;
+                       }
+               }
+               _lastSelectedPoint = selectedPoint;
+
+               if (_image == null || width != _image.getWidth() || height != _image.getHeight())
+               {
+                       createBackgroundImage();
+               }
+               // draw buffered image onto g
+               g.drawImage(_image, 0, 0, width, height, COLOR_BG, null);
+
+               // draw selected range, if any
+               if (_trackInfo.getSelection().hasRangeSelected() && !_zoomDragging)
+               {
+                       int rangeStart = _trackInfo.getSelection().getStart();
+                       int rangeEnd = _trackInfo.getSelection().getEnd();
+                       g.setColor(COLOR_CURR_RANGE);
+                       for (int i=rangeStart; i<=rangeEnd; i++)
+                       {
+                               x = width/2 + (int) ((_track.getX(i) - _offsetX) / _scale * _zoomScale);
+                               y = height/2 - (int) ((_track.getY(i) - _offsetY) / _scale * _zoomScale);
+                               if (x > BORDER_WIDTH && x < (width - BORDER_WIDTH)
+                                       && y < (height - BORDER_WIDTH) && y > BORDER_WIDTH)
+                               {
+                                       g.drawOval(x - 2, y - 2, 4, 4);
+                               }
+                       }
+               }
+
+               // Highlight selected point
+               if (selectedPoint >= 0 && !_zoomDragging)
+               {
+                       g.setColor(COLOR_CROSSHAIRS);
+                       x = width/2 + (int) ((_track.getX(selectedPoint) - _offsetX) / _scale * _zoomScale);
+                       y = height/2 - (int) ((_track.getY(selectedPoint) - _offsetY) / _scale * _zoomScale);
+                       if (x > BORDER_WIDTH && x < (width - BORDER_WIDTH)
+                               && y < (height - BORDER_WIDTH) && y > BORDER_WIDTH)
+                       {
+                               // Draw cross-hairs for current point
+                               g.drawLine(x, BORDER_WIDTH, x, height - BORDER_WIDTH);
+                               g.drawLine(BORDER_WIDTH, y, width - BORDER_WIDTH, y);
+
+                               // Show selected point afterwards to make sure it's on top
+                               g.drawOval(x - 2, y - 2, 4, 4);
+                               g.drawOval(x - 3, y - 3, 6, 6);
+                       }
+               }
+
+               if (_zoomDragging)
+               {
+                       g.setColor(COLOR_CROSSHAIRS);
+                       g.drawLine(_zoomDragFromX, _zoomDragFromY, _zoomDragFromX, _zoomDragToY);
+                       g.drawLine(_zoomDragFromX, _zoomDragFromY, _zoomDragToX, _zoomDragFromY);
+                       g.drawLine(_zoomDragToX, _zoomDragFromY, _zoomDragToX, _zoomDragToY);
+                       g.drawLine(_zoomDragFromX, _zoomDragToY, _zoomDragToX, _zoomDragToY);
+               }
+       }
+
+
+       /**
+        * Draw the map onto an offscreen image
+        */
+       private void createBackgroundImage()
+       {
+               int width = getWidth();
+               int height = getHeight();
+               int x, y;
+               // Make a new image and initialise it
+               _image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+               Graphics bufferedG = _image.getGraphics();
+               super.paint(bufferedG);
+
+               // Loop and show all points
+               int numPoints = _track.getNumPoints();
+               bufferedG.setColor(COLOR_POINT);
+               int halfWidth = width/2;
+               int halfHeight = height/2;
+               for (int i=0; i<numPoints; i++)
+               {
+                       x = halfWidth + (int) ((_track.getX(i) - _offsetX) / _scale * _zoomScale);
+                       y = halfHeight - (int) ((_track.getY(i) - _offsetY) / _scale * _zoomScale);
+                       if (x > BORDER_WIDTH && x < (width - BORDER_WIDTH)
+                               && y < (height - BORDER_WIDTH) && y > BORDER_WIDTH)
+                       {
+                               bufferedG.drawOval(x - 2, y - 2, 4, 4);
+                       }
+               }
+
+               // Loop again and show waypoints with names
+               bufferedG.setColor(COLOR_WAYPT_NAME);
+               FontMetrics fm = bufferedG.getFontMetrics();
+               int nameHeight = fm.getHeight();
+               int numWaypointNamesShown = 0;
+               for (int i=0; i<numPoints; i++)
+               {
+                       DataPoint point = _track.getPoint(i);
+                       String waypointName = point.getFieldValue(Field.WAYPT_NAME);
+                       if (waypointName != null && !waypointName.equals("") && numWaypointNamesShown < LIMIT_WAYPOINT_NAMES)
+                       {
+                               x = halfWidth + (int) ((_track.getX(i) - _offsetX) / _scale * _zoomScale);
+                               y = halfHeight - (int) ((_track.getY(i) - _offsetY) / _scale * _zoomScale);
+                               if (x > BORDER_WIDTH && x < (width - BORDER_WIDTH)
+                                               && y < (height - BORDER_WIDTH) && y > BORDER_WIDTH)
+                               {
+                                       bufferedG.fillOval(x - 3, y - 3, 6, 6);
+                                       // Figure out where to draw name so it doesn't obscure track
+                                       int nameWidth = fm.stringWidth(waypointName);
+                                       if (nameWidth < (width - 2 * BORDER_WIDTH))
+                                       {
+                                               double nameAngle = 0.3;
+                                               double nameRadius = 1.0;
+                                               boolean drawnName = false;
+                                               while (!drawnName)
+                                               {
+                                                       int nameX = x + (int) (nameRadius * Math.cos(nameAngle)) - (nameWidth/2);
+                                                       int nameY = y + (int) (nameRadius * Math.sin(nameAngle)) + (nameHeight/2);
+                                                       if (nameX > BORDER_WIDTH && (nameX + nameWidth) < (width - BORDER_WIDTH)
+                                                               && nameY < (height - BORDER_WIDTH) && (nameY - nameHeight) > BORDER_WIDTH)
+                                                       {
+                                                               // name can fit in grid - does it overlap data points?
+                                                               if (!overlapsPoints(nameX, nameY, nameWidth, nameHeight) || nameRadius > 50.0)
+                                                               {
+                                                                       bufferedG.drawString(waypointName, nameX, nameY);
+                                                                       drawnName = true;
+                                                                       numWaypointNamesShown++;
+                                                               }
+                                                       }
+                                                       nameAngle += 0.08;
+                                                       nameRadius += 0.2;
+                                                       // wasn't room within the radius, so don't print name
+                                                       if (nameRadius > 50.0)
+                                                       {
+                                                               drawnName = true;
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+               }
+       }
+
+
+       /**
+        * Tests whether there are any data points within the specified x,y rectangle
+        * @param inX left X coordinate
+        * @param inY bottom Y coordinate
+        * @param inWidth width of rectangle
+        * @param inHeight height of rectangle
+        * @return true if there's at least one data point in the rectangle
+        */
+       private boolean overlapsPoints(int inX, int inY, int inWidth, int inHeight)
+       {
+               // if (true) return true;
+               for (int x=0; x<inWidth; x++)
+               {
+                       for (int y=0; y<inHeight; y++)
+                       {
+                               int pixelColor = _image.getRGB(inX + x, inY - y);
+                               if (pixelColor != -1) return true;
+                       }
+               }
+               return false;
+       }
+
+
+       /**
+        * Make the popup menu for right-clicking the map
+        */
+       private void makePopup()
+       {
+               _popup = new JPopupMenu();
+               JMenuItem zoomIn = new JMenuItem(I18nManager.getText("menu.map.zoomin"));
+               zoomIn.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               zoomMap(true);
+                       }});
+               zoomIn.setEnabled(true);
+               _popup.add(zoomIn);
+               JMenuItem zoomOut = new JMenuItem(I18nManager.getText("menu.map.zoomout"));
+               zoomOut.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               zoomMap(false);
+                       }});
+               zoomOut.setEnabled(true);
+               _popup.add(zoomOut);
+               JMenuItem zoomFull = new JMenuItem(I18nManager.getText("menu.map.zoomfull"));
+               zoomFull.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               zoomToFullScale();
+                       }});
+               zoomFull.setEnabled(true);
+               _popup.add(zoomFull);
+               _autoPanMenuItem = new JCheckBoxMenuItem(I18nManager.getText("menu.map.autopan"));
+               _autoPanMenuItem.setSelected(true);
+               _popup.add(_autoPanMenuItem);
+       }
+
+
+       /**
+        * Zoom map to full scale
+        */
+       private void zoomToFullScale()
+       {
+               _zoomScale = 1.0;
+               _offsetX = 0.0;
+               _offsetY = 0.0;
+               _numPoints = 0;
+               dataUpdated();
+       }
+
+
+       /**
+        * Zoom map either in or out by one step
+        * @param inZoomIn true to zoom in, false for out
+        */
+       private void zoomMap(boolean inZoomIn)
+       {
+               if (inZoomIn)
+               {
+                       // Zoom in
+                       _zoomScale *= ZOOM_SCALE_FACTOR;
+               }
+               else
+               {
+                       // Zoom out
+                       _zoomScale /= ZOOM_SCALE_FACTOR;
+                       if (_zoomScale < 0.5) _zoomScale = 0.5;
+               }
+               _numPoints = 0;
+               dataUpdated();
+       }
+
+
+       /**
+        * Pan the map by the specified amounts
+        * @param inUp upwards pan
+        * @param inRight rightwards pan
+        */
+       private void panMap(int inUp, int inRight)
+       {
+               double panFactor = _scale / _zoomScale;
+               _offsetY = _offsetY + (inUp * panFactor);
+               _offsetX = _offsetX - (inRight * panFactor);
+               // Limit pan to sensible range??
+               _numPoints = 0;
+               dataUpdated();
+       }
+
+
+       /**
+        * React to click on map display
+        */
+       public void mouseClicked(MouseEvent e)
+       {
+               this.requestFocus();
+               if (_track != null)
+               {
+                       int xClick = e.getX();
+                       int yClick = e.getY();
+                       // Check click is within main area (not in border)
+                       if (xClick > BORDER_WIDTH && yClick > BORDER_WIDTH && xClick < (getWidth() - BORDER_WIDTH)
+                               && yClick < (getHeight() - BORDER_WIDTH))
+                       {
+                               // Check left click or right click
+                               if (e.isMetaDown())
+                               {
+                                       // Only show popup if track has data
+                                       if (_track != null && _track.getNumPoints() > 0)
+                                               _popup.show(this, e.getX(), e.getY());
+                               }
+                               else
+                               {
+                                       // Find point within range of click point
+                                       double pointX = (xClick - getWidth()/2) * _scale / _zoomScale + _offsetX;
+                                       double pointY = (getHeight()/2 - yClick) * _scale / _zoomScale + _offsetY;
+                                       int selectedPointIndex = _track.getNearestPointIndex(
+                                               pointX, pointY, CLICK_SENSITIVITY * _scale, false);
+                                       // Select the given point (or deselect if no point was found)
+                                       _trackInfo.getSelection().selectPoint(selectedPointIndex);
+                               }
+                       }
+               }
+       }
+
+
+       /**
+        * Respond to mouse released to reset dragging
+        * @see java.awt.event.MouseListener#mouseReleased(java.awt.event.MouseEvent)
+        */
+       public void mouseReleased(MouseEvent e)
+       {
+               _dragStartX = _dragStartY = -1;
+               if (e.isMetaDown())
+               {
+                       if (_zoomDragFromX >= 0 || _zoomDragFromY >= 0)
+                       {
+                               // zoom area marked out - calculate offset and zoom
+                               int xPan = (getWidth() - _zoomDragFromX - e.getX()) / 2;
+                               int yPan = (getHeight() - _zoomDragFromY - e.getY()) / 2;
+                               double xZoom = Math.abs(getWidth() * 1.0 / (e.getX() - _zoomDragFromX));
+                               double yZoom = Math.abs(getHeight() * 1.0 / (e.getY() - _zoomDragFromY));
+                               double extraZoom = (xZoom>yZoom?yZoom:xZoom);
+                               // Pan first to ensure pan occurs with correct scale
+                               panMap(yPan, xPan);
+                               // Then zoom in and request repaint
+                               _zoomScale = _zoomScale * extraZoom;
+                               _image = null;
+                               repaint();
+                       }
+                       _zoomDragFromX = _zoomDragFromY = -1;
+                       _zoomDragging = false;
+               }
+       }
+
+
+       /**
+        * Respond to mouse wheel events to zoom the map
+        * @see java.awt.event.MouseWheelListener#mouseWheelMoved(java.awt.event.MouseWheelEvent)
+        */
+       public void mouseWheelMoved(MouseWheelEvent e)
+       {
+               zoomMap(e.getWheelRotation() < 0);
+       }
+
+
+       /**
+        * @see java.awt.event.KeyListener#keyPressed(java.awt.event.KeyEvent)
+        */
+       public void keyPressed(KeyEvent e)
+       {
+               int code = e.getKeyCode();
+               // Check for meta key
+               if (e.isControlDown())
+               {
+                       // Check for arrow keys to zoom in and out
+                       if (code == KeyEvent.VK_UP)
+                               zoomMap(true);
+                       else if (code == KeyEvent.VK_DOWN)
+                               zoomMap(false);
+                       // Key nav for next/prev point
+                       else if (code == KeyEvent.VK_LEFT)
+                               _trackInfo.getSelection().selectPreviousPoint();
+                       else if (code == KeyEvent.VK_RIGHT)
+                               _trackInfo.getSelection().selectNextPoint();
+               }
+               else
+               {
+                       // Check for arrow keys to pan
+                       int upwardsPan = 0;
+                       if (code == KeyEvent.VK_UP)
+                               upwardsPan = PAN_DISTANCE;
+                       else if (code == KeyEvent.VK_DOWN)
+                               upwardsPan = -PAN_DISTANCE;
+                       int rightwardsPan = 0;
+                       if (code == KeyEvent.VK_RIGHT)
+                               rightwardsPan = -PAN_DISTANCE;
+                       else if (code == KeyEvent.VK_LEFT)
+                               rightwardsPan = PAN_DISTANCE;
+                       panMap(upwardsPan, rightwardsPan);
+                       // Check for delete key to delete current point
+                       if (code == KeyEvent.VK_DELETE && _trackInfo.getSelection().getCurrentPointIndex() >= 0)
+                               _app.deleteCurrentPoint();
+               }
+       }
+
+
+       /**
+        * @see java.awt.event.KeyListener#keyReleased(java.awt.event.KeyEvent)
+        */
+       public void keyReleased(KeyEvent e)
+       {
+               // ignore
+       }
+
+
+       /**
+        * @see java.awt.event.KeyListener#keyTyped(java.awt.event.KeyEvent)
+        */
+       public void keyTyped(KeyEvent e)
+       {
+               // ignore
+       }
+
+
+       /**
+        * @see java.awt.event.MouseMotionListener#mouseDragged(java.awt.event.MouseEvent)
+        */
+       public void mouseDragged(MouseEvent e)
+       {
+               if (!e.isMetaDown())
+               {
+                       if (_dragStartX > 0)
+                       {
+                               int xShift = e.getX() - _dragStartX;
+                               int yShift = e.getY() - _dragStartY;
+                               panMap(yShift, xShift);
+                       }
+                       _dragStartX = e.getX();
+                       _dragStartY = e.getY();
+               }
+               else
+               {
+                       // Right click-and-drag for zoom
+                       if (_zoomDragFromX < 0 || _zoomDragFromY < 0)
+                       {
+                               _zoomDragFromX = e.getX();
+                               _zoomDragFromY = e.getY();
+                       }
+                       else
+                       {
+                               _zoomDragToX = e.getX();
+                               _zoomDragToY = e.getY();
+                               _zoomDragging = true;
+                       }
+                       repaint();
+               }
+       }
+
+
+       /**
+        * @see java.awt.event.MouseMotionListener#mouseMoved(java.awt.event.MouseEvent)
+        */
+       public void mouseMoved(MouseEvent e)
+       {
+               // ignore
+       }
+}
diff --git a/tim/prune/gui/MenuManager.java b/tim/prune/gui/MenuManager.java
new file mode 100644 (file)
index 0000000..665142e
--- /dev/null
@@ -0,0 +1,337 @@
+package tim.prune.gui;
+
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.InputEvent;
+import java.awt.event.KeyEvent;
+
+import javax.swing.JFrame;
+import javax.swing.JMenu;
+import javax.swing.JMenuBar;
+import javax.swing.JMenuItem;
+import javax.swing.KeyStroke;
+
+import tim.prune.App;
+import tim.prune.DataSubscriber;
+import tim.prune.I18nManager;
+import tim.prune.data.Selection;
+import tim.prune.data.Track;
+import tim.prune.data.TrackInfo;
+
+/**
+ * Class to manage the menu bar,
+ * including enabling and disabling the menu items
+ */
+public class MenuManager implements DataSubscriber
+{
+       private JFrame _parent = null;
+       private App _app = null;
+       private Track _track = null;
+       private Selection _selection = null;
+
+       // Menu items which need enabling/disabling
+       JMenuItem _saveItem = null;
+       JMenuItem _exportItem = null;
+       JMenuItem _undoItem = null;
+       JMenuItem _clearUndoItem = null;
+       JMenuItem _deletePointItem = null;
+       JMenuItem _deleteRangeItem = null;
+       JMenuItem _deleteDuplicatesItem = null;
+       JMenuItem _compressItem = null;
+       JMenuItem _interpolateItem = null;
+       JMenuItem _selectAllItem = null;
+       JMenuItem _selectNoneItem = null;
+       JMenuItem _show3dItem = null;
+       JMenuItem _reverseItem = null;
+       JMenu     _rearrangeMenu = null;
+       JMenuItem _rearrangeStartItem = null;
+       JMenuItem _rearrangeEndItem = null;
+       JMenuItem _rearrangeNearestItem = null;
+
+
+       /**
+        * Constructor
+        * @param inParent parent object for dialogs
+        * @param inApp application to call on menu actions
+        */
+       public MenuManager(JFrame inParent, App inApp, TrackInfo inTrackInfo)
+       {
+               _parent = inParent;
+               _app = inApp;
+               _track = inTrackInfo.getTrack();
+               _selection = inTrackInfo.getSelection();
+       }
+
+
+       /**
+        * Create a JMenuBar containing all menu items
+        * @return JMenuBar
+        */
+       public JMenuBar createMenuBar()
+       {
+               JMenuBar menubar = new JMenuBar();
+               JMenu fileMenu = new JMenu(I18nManager.getText("menu.file"));
+               JMenuItem openMenuItem = new JMenuItem(I18nManager.getText("menu.file.open"));
+               openMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, InputEvent.CTRL_DOWN_MASK));
+               openMenuItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.openFile();
+                       }
+               });
+               fileMenu.add(openMenuItem);
+               _saveItem = new JMenuItem(I18nManager.getText("menu.file.save"), KeyEvent.VK_S);
+               _saveItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.saveFile();
+                       }
+               });
+               _saveItem.setEnabled(false);
+               fileMenu.add(_saveItem);
+               // Export
+               _exportItem = new JMenuItem(I18nManager.getText("menu.file.export"));
+               _exportItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.exportKml();
+                       }
+               });
+               _exportItem.setEnabled(false);
+               fileMenu.add(_exportItem);
+               fileMenu.addSeparator();
+               JMenuItem exitMenuItem = new JMenuItem(I18nManager.getText("menu.file.exit"));
+               exitMenuItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.exit();
+                       }
+               });
+               fileMenu.add(exitMenuItem);
+               menubar.add(fileMenu);
+               JMenu editMenu = new JMenu(I18nManager.getText("menu.edit"));
+               editMenu.setMnemonic(KeyEvent.VK_E);
+               _undoItem = new JMenuItem(I18nManager.getText("menu.edit.undo"));
+               _undoItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.beginUndo();
+                       }
+               });
+               _undoItem.setEnabled(false);
+               editMenu.add(_undoItem);
+               _clearUndoItem = new JMenuItem(I18nManager.getText("menu.edit.clearundo"));
+               _clearUndoItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.clearUndo();
+                       }
+               });
+               _clearUndoItem.setEnabled(false);
+               editMenu.add(_clearUndoItem);
+               editMenu.addSeparator();
+               _deletePointItem = new JMenuItem(I18nManager.getText("menu.edit.deletepoint"));
+               _deletePointItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.deleteCurrentPoint();
+                       }
+               });
+               _deletePointItem.setEnabled(false);
+               editMenu.add(_deletePointItem);
+               _deleteRangeItem = new JMenuItem(I18nManager.getText("menu.edit.deleterange"));
+               _deleteRangeItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.deleteSelectedRange();
+                       }
+               });
+               _deleteRangeItem.setEnabled(false);
+               editMenu.add(_deleteRangeItem);
+               _deleteDuplicatesItem = new JMenuItem(I18nManager.getText("menu.edit.deleteduplicates"));
+               _deleteDuplicatesItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.deleteDuplicates();
+                       }
+               });
+               _deleteDuplicatesItem.setEnabled(false);
+               editMenu.add(_deleteDuplicatesItem);
+               _compressItem = new JMenuItem(I18nManager.getText("menu.edit.compress"));
+               _compressItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.compressTrack();
+                       }
+               });
+               _compressItem.setEnabled(false);
+               editMenu.add(_compressItem);
+               editMenu.addSeparator();
+               _interpolateItem = new JMenuItem(I18nManager.getText("menu.edit.interpolate"));
+               _interpolateItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.interpolateSelection();
+                       }
+               });
+               _interpolateItem.setEnabled(false);
+               editMenu.add(_interpolateItem);
+               _reverseItem = new JMenuItem(I18nManager.getText("menu.edit.reverse"));
+               _reverseItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.reverseRange();
+                       }
+               });
+               _reverseItem.setEnabled(false);
+               editMenu.add(_reverseItem);
+               // Rearrange waypoints
+               _rearrangeMenu = new JMenu(I18nManager.getText("menu.edit.rearrange"));
+               _rearrangeMenu.setEnabled(false);
+               _rearrangeStartItem = new JMenuItem(I18nManager.getText("menu.edit.rearrange.start"));
+               _rearrangeStartItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.rearrangeWaypoints(App.REARRANGE_TO_START);
+                       }
+               });
+               _rearrangeStartItem.setEnabled(true);
+               _rearrangeMenu.add(_rearrangeStartItem);
+               _rearrangeEndItem = new JMenuItem(I18nManager.getText("menu.edit.rearrange.end"));
+               _rearrangeEndItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.rearrangeWaypoints(App.REARRANGE_TO_END);
+                       }
+               });
+               _rearrangeEndItem.setEnabled(true);
+               _rearrangeMenu.add(_rearrangeEndItem);
+               _rearrangeNearestItem = new JMenuItem(I18nManager.getText("menu.edit.rearrange.nearest"));
+               _rearrangeNearestItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.rearrangeWaypoints(App.REARRANGE_TO_NEAREST);
+                       }
+               });
+               _rearrangeNearestItem.setEnabled(true);
+               _rearrangeMenu.add(_rearrangeNearestItem);
+               editMenu.add(_rearrangeMenu);
+               menubar.add(editMenu);
+
+               // Select menu
+               JMenu selectMenu = new JMenu(I18nManager.getText("menu.select"));
+               _selectAllItem = new JMenuItem(I18nManager.getText("menu.select.all"));
+               _selectAllItem.setEnabled(false);
+               _selectAllItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.selectAll();
+                       }
+               });
+               selectMenu.add(_selectAllItem);
+               _selectNoneItem = new JMenuItem(I18nManager.getText("menu.select.none"));
+               _selectNoneItem.setEnabled(false);
+               _selectNoneItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _app.selectNone();
+                       }
+               });
+               selectMenu.add(_selectNoneItem);
+               menubar.add(selectMenu);
+
+               // Add 3d menu if available
+               if (isJava3dEnabled())
+               {
+                       JMenu threeDMenu = new JMenu(I18nManager.getText("menu.3d"));
+                       _show3dItem = new JMenuItem(I18nManager.getText("menu.3d.show3d"));
+                       _show3dItem.addActionListener(new ActionListener() {
+                               public void actionPerformed(ActionEvent e)
+                               {
+                                       _app.show3dWindow();
+                               }
+                       });
+                       _show3dItem.setEnabled(false);
+                       threeDMenu.add(_show3dItem);
+                       menubar.add(threeDMenu);
+               }
+
+               // Help menu for About
+               JMenu helpMenu = new JMenu(I18nManager.getText("menu.help"));
+               JMenuItem aboutItem = new JMenuItem(I18nManager.getText("menu.help.about"));
+               aboutItem.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               new AboutScreen(_parent).show();
+                       }
+               });
+               helpMenu.add(aboutItem);
+               menubar.add(helpMenu);
+
+               return menubar;
+       }
+
+
+       /**
+        * Method to update menu when file loaded
+        */
+       public void informFileLoaded()
+       {
+               // save, undo, delete enabled
+               _saveItem.setEnabled(true);
+               _undoItem.setEnabled(true);
+               _deleteDuplicatesItem.setEnabled(true);
+               _compressItem.setEnabled(true);
+       }
+
+
+       /**
+        * @return true if 3d capability is installed
+        */
+       private static boolean isJava3dEnabled()
+       {
+               boolean has3d = false;
+               try
+               {
+                       Class universeClass = Class.forName("com.sun.j3d.utils.universe.SimpleUniverse");
+                       has3d = true;
+               }
+               catch (ClassNotFoundException e)
+               {
+                       // no java3d classes available
+               }
+               return has3d;
+       }
+
+
+       /**
+        * @see tim.prune.DataSubscriber#dataUpdated(tim.prune.data.Track)
+        */
+       public void dataUpdated()
+       {
+               boolean hasData = (_track != null && _track.getNumPoints() > 0);
+               // set functions which require data
+               _saveItem.setEnabled(hasData);
+               _exportItem.setEnabled(hasData);
+               _deleteDuplicatesItem.setEnabled(hasData);
+               _compressItem.setEnabled(hasData);
+               _rearrangeMenu.setEnabled(hasData && _track.hasMixedData());
+               _selectAllItem.setEnabled(hasData);
+               _selectNoneItem.setEnabled(hasData);
+               if (_show3dItem != null)
+                       _show3dItem.setEnabled(hasData);
+               // is undo available?
+               boolean hasUndo = !_app.getUndoStack().isEmpty();
+               _undoItem.setEnabled(hasUndo);
+               _clearUndoItem.setEnabled(hasUndo);
+               // is there a current point?
+               boolean hasPoint = (hasData && _selection.getCurrentPointIndex() >= 0);
+               _deletePointItem.setEnabled(hasPoint);
+               // is there a current range?
+               boolean hasRange = (hasData && _selection.hasRangeSelected());
+               _deleteRangeItem.setEnabled(hasRange);
+               _interpolateItem.setEnabled(hasRange
+                       && (_selection.getEnd() - _selection.getStart()) == 1);
+               _reverseItem.setEnabled(hasRange);
+       }
+}
diff --git a/tim/prune/gui/ProfileChart.java b/tim/prune/gui/ProfileChart.java
new file mode 100644 (file)
index 0000000..431da0d
--- /dev/null
@@ -0,0 +1,207 @@
+package tim.prune.gui;
+
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.event.MouseEvent;
+
+import tim.prune.I18nManager;
+import tim.prune.data.AltitudeRange;
+import tim.prune.data.Track;
+import tim.prune.data.TrackInfo;
+
+/**
+ * Chart component for the profile display
+ */
+public class ProfileChart extends GenericChart
+{
+       private double _xScaleFactor = 0.0;
+       private static final int[] ALTITUDE_SCALES = {10000, 5000, 2000, 1000, 500, 200, 100, 50};
+       private static final Color COLOR_LINES       = Color.GRAY;
+       private static final Color COLOR_ALT_BARS    = Color.BLUE;
+       private static final Color COLOR_SELECTED    = Color.RED;
+       private static final Color COLOR_SELECTED_BG = Color.ORANGE;
+       private static final Color COLOR_ALT_SCALE   = Color.RED;
+
+
+       /**
+        * Constructor
+        * @param inTrackInfo Track info object
+        */
+       public ProfileChart(TrackInfo inTrackInfo)
+       {
+               super(inTrackInfo);
+               MINIMUM_SIZE = new Dimension(200, 100);
+               addMouseListener(this);
+       }
+
+
+       /**
+        * Override paint method to draw map
+        */
+       public void paint(Graphics g)
+       {
+               super.paint(g);
+               if (_track != null && _track.getNumPoints() > 0)
+               {
+                       int width = getWidth();
+                       int height = getHeight();
+                       AltitudeRange altitudeRange = _track.getAltitudeRange();
+                       int minAltitude = altitudeRange.getMinimum();
+                       int maxAltitude = altitudeRange.getMaximum();
+
+                       // message if no altitudes in track
+                       if (minAltitude < 0 || maxAltitude < 0)
+                       {
+                               g.setColor(COLOR_LINES);
+                               g.drawString(I18nManager.getText("display.noaltitudes"), 50, height/2);
+                               return;
+                       }
+
+                       // altitude profile
+                       int numPoints = _track.getNumPoints();
+                       _xScaleFactor = 1.0 * (width - 2 * BORDER_WIDTH) / numPoints;
+                       double yScaleFactor = 1.0 * (height - 2 * BORDER_WIDTH) /
+                         (altitudeRange.getMaximum() - minAltitude);
+                       int barWidth = (int) (_xScaleFactor + 1.0);
+                       int selectedPoint = _trackInfo.getSelection().getCurrentPointIndex();
+
+                       // horizontal lines for scale - set to round numbers eg 500m
+                       int lineScale = getLineScale(minAltitude, maxAltitude);
+                       int altitude = 0;
+                       int y = 0;
+                       if (lineScale > 1)
+                       {
+                               g.setColor(COLOR_LINES);
+                               while (altitude < maxAltitude)
+                               {
+                                       if (altitude > minAltitude)
+                                       {
+                                               y = height - BORDER_WIDTH - (int) (yScaleFactor * (altitude - minAltitude));
+                                               g.drawLine(BORDER_WIDTH + 1, y, width - BORDER_WIDTH - 1, y);
+                                       }
+                                       altitude += lineScale;
+                               }
+                       }
+
+                       // loop through points
+                       int chartFormat = altitudeRange.getFormat();
+                       for (int p = 0; p < numPoints; p++)
+                       {
+                               int x = (int) (_xScaleFactor * p);
+                               if (p == selectedPoint)
+                               {
+                                       g.setColor(COLOR_SELECTED_BG);
+                                       g.fillRect(BORDER_WIDTH + x, BORDER_WIDTH+1, barWidth, height-2*BORDER_WIDTH-2);
+                                       g.setColor(COLOR_SELECTED);
+                               }
+                               else
+                               {
+                                       g.setColor(COLOR_ALT_BARS);
+                               }
+                               if (_track.getPoint(p).getAltitude().isValid())
+                               {
+                                       altitude = _track.getPoint(p).getAltitude().getValue(chartFormat);
+                                       y = (int) (yScaleFactor * (altitude - minAltitude));
+                                       g.fillRect(BORDER_WIDTH+x, height-BORDER_WIDTH - y, barWidth, y);
+                               }
+                       }
+                       // Draw numbers on top of the graph to mark scale
+                       if (lineScale > 1)
+                       {
+                               int textHeight = g.getFontMetrics().getHeight();
+                               altitude = 0;
+                               y = 0;
+                               g.setColor(COLOR_ALT_SCALE);
+                               while (altitude < maxAltitude)
+                               {
+                                       if (altitude > minAltitude)
+                                       {
+                                               y = height - BORDER_WIDTH - (int) (yScaleFactor * (altitude - minAltitude));
+                                               // Limit y so String isn't above border
+                                               if (y < (BORDER_WIDTH + textHeight))
+                                               {
+                                                       y = BORDER_WIDTH + textHeight;
+                                               }
+                                               g.drawString(""+altitude, BORDER_WIDTH + 5, y);
+                                       }
+                                       altitude += lineScale;
+                               }
+                       }
+               }
+       }
+
+
+       /**
+        * Work out the scale for the horizontal lines
+        * @param inMin min altitude of data
+        * @param inMax max altitude of data
+        * @return scale separation, or -1 for no scale
+        */
+       private int getLineScale(int inMin, int inMax)
+       {
+               if ((inMax - inMin) < 50 || inMax < 0)
+               {
+                       return -1;
+               }
+               int numScales = ALTITUDE_SCALES.length;
+               int scale = 0;
+               int numLines = 0;
+               int altitude = 0;
+               for (int i=0; i<numScales; i++)
+               {
+                       scale = ALTITUDE_SCALES[i];
+                       if (scale < inMax)
+                       {
+                               numLines = 0;
+                               altitude = 0;
+                               while (altitude < inMax)
+                               {
+                                       altitude += scale;
+                                       if (altitude > inMin)
+                                       {
+                                               numLines++;
+                                       }
+                               }
+                               if (numLines > 2)
+                               {
+                                       return scale;
+                               }
+                       }
+               }
+               // no suitable scale found so just use minimum
+               return ALTITUDE_SCALES[numScales-1];
+       }
+
+
+       /**
+        * Method to inform map that data has changed
+        */
+       public void dataUpdated(Track inTrack)
+       {
+               _track = inTrack;
+               repaint();
+       }
+
+
+       /**
+        * React to click on profile display
+        */
+       public void mouseClicked(MouseEvent e)
+       {
+               // ignore right clicks
+               if (_track != null && !e.isMetaDown())
+               {
+                       int xClick = e.getX();
+                       int yClick = e.getY();
+                       // Check click is within main area (not in border)
+                       if (xClick > BORDER_WIDTH && yClick > BORDER_WIDTH && xClick < (getWidth() - BORDER_WIDTH)
+                               && yClick < (getHeight() - BORDER_WIDTH))
+                       {
+                               // work out which data point is nearest and select it
+                               int pointNum = (int) ((e.getX() - BORDER_WIDTH) / _xScaleFactor);
+                               _trackInfo.getSelection().selectPoint(pointNum);
+                       }
+               }
+       }
+}
diff --git a/tim/prune/gui/UndoManager.java b/tim/prune/gui/UndoManager.java
new file mode 100644 (file)
index 0000000..5c3a992
--- /dev/null
@@ -0,0 +1,95 @@
+package tim.prune.gui;
+
+import java.awt.FlowLayout;
+import java.awt.BorderLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.util.Stack;
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.ListSelectionModel;
+import javax.swing.event.ListSelectionEvent;
+import javax.swing.event.ListSelectionListener;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.undo.UndoOperation;
+
+/**
+ * Class to manage the selection of actions to undo
+ */
+public class UndoManager
+{
+       private App _app;
+       private JDialog _dialog;
+       private JList _actionList;
+
+
+       /**
+        * Constructor
+        */
+       public UndoManager(App inApp, JFrame inFrame)
+       {
+               _app = inApp;
+               _dialog = new JDialog(inFrame, I18nManager.getText("dialog.undo.title"), true);
+               _dialog.setLocationRelativeTo(inFrame);
+               JPanel mainPanel = new JPanel();
+               mainPanel.setLayout(new BorderLayout(3, 3));
+               mainPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+               Stack undoStack = inApp.getUndoStack();
+               mainPanel.add(new JLabel(I18nManager.getText("dialog.undo.pretext")), BorderLayout.NORTH);
+
+               String[] undoActions = new String[undoStack.size()];
+               for (int i=0; i<undoStack.size(); i++)
+               {
+                       undoActions[i] = ((UndoOperation) undoStack.elementAt(undoStack.size()-1-i)).getDescription();
+               }
+               _actionList = new JList(undoActions);
+               _actionList.setSelectionMode(ListSelectionModel.SINGLE_INTERVAL_SELECTION);
+               _actionList.setSelectedIndex(0);
+               _actionList.addListSelectionListener(new ListSelectionListener()
+                       {
+                               public void valueChanged(ListSelectionEvent e)
+                               {
+                                       if (_actionList.getMinSelectionIndex() > 0)
+                                       {
+                                               _actionList.setSelectionInterval(0, _actionList.getMaxSelectionIndex());
+                                       }
+                               }
+                       });
+               mainPanel.add(new JScrollPane(_actionList), BorderLayout.CENTER);
+               // Buttons
+               JPanel buttonPanel = new JPanel();
+               buttonPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
+               JButton okButton = new JButton("OK");
+               okButton.addActionListener(new ActionListener()
+                       {
+                               public void actionPerformed(ActionEvent e)
+                               {
+                                       _app.undoActions(_actionList.getMaxSelectionIndex() + 1);
+                                       _dialog.dispose();
+                               }
+                       });
+               buttonPanel.add(okButton);
+               JButton cancelButton = new JButton("Cancel");
+               cancelButton.addActionListener(new ActionListener()
+                       {
+                               public void actionPerformed(ActionEvent e)
+                               {
+                                       _dialog.dispose();
+                               }
+                       });
+               buttonPanel.add(cancelButton);
+               mainPanel.add(buttonPanel, BorderLayout.SOUTH);
+               _dialog.getContentPane().add(mainPanel);
+               _dialog.pack();
+               _dialog.show();
+       }
+
+}
diff --git a/tim/prune/lang/prune-texts.properties b/tim/prune/lang/prune-texts.properties
new file mode 100644 (file)
index 0000000..01f9dad
--- /dev/null
@@ -0,0 +1,187 @@
+# Text entries for the Prune application
+# English entries as default - others can be added
+
+# Menu entries
+menu.file=File
+menu.file.open=Open
+menu.file.save=Save
+menu.file.export=Export KML
+menu.file.exit=Exit
+menu.edit=Edit
+menu.edit.undo=Undo
+menu.edit.clearundo=Clear undo list
+menu.edit.deletepoint=Delete point
+menu.edit.deleterange=Delete range
+menu.edit.deleteduplicates=Delete duplicates
+menu.edit.compress=Compress track
+menu.edit.interpolate=Interpolate
+menu.edit.reverse=Reverse range
+menu.edit.rearrange=Rearrange waypoints
+menu.edit.rearrange.start=All to start of file
+menu.edit.rearrange.end=All to end of file
+menu.edit.rearrange.nearest=Each to nearest track point
+menu.select=Select
+menu.select.all=Select all
+menu.select.none=Select none
+menu.3d=Three-D
+menu.3d.show3d=Show in Three-D
+menu.help=Help
+menu.help.about=About Prune
+# Popup menu for map
+menu.map.zoomin=Zoom in
+menu.map.zoomout=Zoom out
+menu.map.zoomfull=Zoom to full scale
+menu.map.autopan=Autopan
+
+# Dialogs
+dialog.exit.confirm.title=Exit prune
+dialog.exit.confirm.text=Your data is not saved. Are you sure you want to exit?
+dialog.openappend.title=Append to existing data
+dialog.openappend.text=Append this data to the data already loaded?
+dialog.deleteduplicates.title=Delete Duplicates
+dialog.deleteduplicates.single.text=duplicate was deleted
+dialog.deleteduplicates.multi.text=duplicates were deleted
+dialog.deleteduplicates.nonefound=No duplicates found
+dialog.compresstrack.title=Compress Track
+dialog.compresstrack.parameter.text=Parameter for compression (lower number = more compression)
+dialog.compresstrack.text=Track compressed
+dialog.compresstrack.single.text=data point was removed
+dialog.compresstrack.multi.text=data points were removed
+dialog.compresstrack.nonefound=No data points could be removed
+dialog.openoptions.title=Open options
+dialog.openoptions.filesnippet=Extract of file
+dialog.load.table.field=Field
+dialog.load.table.datatype=Data Type
+dialog.load.table.description=Description
+dialog.delimiter.label=Field delimiter
+dialog.delimiter.comma=Comma ,
+dialog.delimiter.tab=Tab
+dialog.delimiter.space=Space
+dialog.delimiter.semicolon=Semicolon ;
+dialog.delimiter.other=Other
+dialog.openoptions.deliminfo.records=records, with 
+dialog.openoptions.deliminfo.fields=fields
+dialog.openoptions.deliminfo.norecords=No records
+dialog.openoptions.tabledesc=Extract of file
+dialog.openoptions.altitudeunits=Altitude units
+dialog.saveoptions.title=Save file
+dialog.save.fieldstosave=Fields to save
+dialog.save.table.field=Field
+dialog.save.table.hasdata=Has data
+dialog.save.table.save=Save
+dialog.save.coordinateunits=Coordinate units
+dialog.save.units.original=Original
+dialog.save.altitudeunits=Altitude units
+dialog.save.oktitle=File saved
+dialog.save.ok1=Successfully saved
+dialog.save.ok2=points to file
+dialog.exportkml.title=Export KML
+dialog.exportkml.text=Please enter a short description for the data
+dialog.confirmreversetrack.title=Confirm reversal
+dialog.confirmreversetrack.text=This track contains timestamp information, which will be out of sequence after a reversal.\nAre you sure you want to reverse this section?
+dialog.interpolate.title=Interpolate points
+dialog.interpolate.parameter.text=Number of points to insert between selected points
+dialog.undo.title=Undo action(s)
+dialog.undo.pretext=Please select the action(s) to undo
+dialog.confirmundo.title=Operation(s) undone
+dialog.confirmundo.single.text=operation undone.
+dialog.confirmundo.multiple.text=operations undone.
+dialog.undo.none.title=Cannot undo
+dialog.undo.none.text=No operations to undo!
+dialog.clearundo.title=Clear undo list
+dialog.clearundo.text=Are you sure you want to clear the undo list?\nAll undo information will be lost!
+dialog.about.title=About
+dialog.about.version=Version
+dialog.about.build=Build
+dialog.about.summarytext1=Prune is a program for loading, displaying and editing data from GPS receivers.
+dialog.about.summarytext2=It is released under the Gnu GPL for free, open, worldwide use and enhancement.<br>Copying, redistribution and modification are permitted and encouraged<br>according to the conditions in the included <code>license.txt</code> file.
+dialog.about.summarytext3=Please see <code style="font-weight:bold">http://activityworkshop.net/</code> for more information and user guides.
+
+# Buttons
+button.ok=OK
+button.back=Back
+button.next=Next
+button.finish=Finish
+button.cancel=Cancel
+button.moveup=Move up
+button.movedown=Move down
+button.startrange=Set range start
+button.endrange=Set range end
+button.deletepoint=Delete point
+button.deleterange=Delete range
+button.exit=Exit
+
+# Display components
+display.nodata=No data loaded
+display.noaltitudes=Track data does not include altitudes
+details.trackdetails=Track details
+details.notrack=No track loaded
+details.track.points=Points
+details.track.file=File
+details.track.numfiles=Number of files
+details.pointdetails=Point details
+details.index.selected=Index
+details.index.of=of
+details.nopointselection=No point selected
+details.norangeselection=No range selected
+details.rangedetails=Range details
+details.range.selected=Selected
+details.range.to=to
+details.altitude.to=to
+details.range.climb=Climb
+details.range.descent=Descent
+details.distanceunits=Distance units
+display.range.time.secs=s
+display.range.time.mins=m
+display.range.time.hours=h
+display.range.time.days=d
+
+# Field names
+fieldname.latitude=Latitude
+fieldname.longitude=Longitude
+fieldname.altitude=Altitude
+fieldname.timestamp=Timestamp
+fieldname.waypointname=Name
+fieldname.waypointtype=Type
+fieldname.newsegment=Segment
+fieldname.custom=Custom
+fieldname.prefix=Field
+fieldname.distance=Distance
+fieldname.duration=Duration
+
+# Measurement units
+units.metres=Metres
+units.metres.short=m
+units.feet=Feet
+units.feet.short=ft
+units.kilometres=Kilometres
+units.kilometres.short=km
+units.miles=Miles
+units.miles.short=mi
+units.degminsec=Deg-min-sec
+units.degmin=Deg-min
+units.deg=Degrees
+
+# Undo operations
+undo.load=load data
+undo.deletepoint=delete point
+undo.deleterange=delete range
+undo.compress=compress track
+undo.insert=insert points
+undo.deleteduplicates=delete duplicates
+undo.reverse=reverse range
+undo.rearrangewaypoints=rearrange waypoints
+
+# Error messages
+error.save.dialogtitle=Error saving data
+error.save.nodata=No data to save
+error.save.fileexists=File already exists. Just to be safe this program won't overwrite it.
+error.save.failed=Failed to save the data to file:
+error.load.dialogtitle=Error loading data
+error.load.noread=Cannot read file
+error.undofailed.title=Undo failed
+error.undofailed.text=Failed to undo operation
+error.function.noop.title=Function had no effect
+error.rearrange.noop=Rearranging waypoints had no effect
+error.function.notimplemented.title=Function not available
+error.function.notimplemented=Sorry, this function has not yet been implemented.
diff --git a/tim/prune/lang/prune-texts_de.properties b/tim/prune/lang/prune-texts_de.properties
new file mode 100644 (file)
index 0000000..2acf4d1
--- /dev/null
@@ -0,0 +1,187 @@
+# Text entries for the Prune application
+# German entries as extra
+
+# Menu entries
+menu.file=Datei
+menu.file.open=Öffnen
+menu.file.save=Speichern
+menu.file.export=KML exportieren
+menu.file.exit=Beenden
+menu.edit=Bearbeiten
+menu.edit.undo=Undo
+menu.edit.clearundo=Undo-Liste löschen
+menu.edit.deletepoint=Punkt löschen
+menu.edit.deleterange=Spanne löschen
+menu.edit.deleteduplicates=Duplikate löschen
+menu.edit.compress=Track komprimieren
+menu.edit.interpolate=Interpolieren
+menu.edit.reverse=Spanne umkehren
+menu.edit.rearrange=Waypoints reorganisieren
+menu.edit.rearrange.start=Alle zum Anfang
+menu.edit.rearrange.end=Alle zum Ende
+menu.edit.rearrange.nearest=Jeder zum nächsten Trackpunkt
+menu.select=Selektieren
+menu.select.all=Alles selektieren
+menu.select.none=Nichts selektieren
+menu.3d=Drei-D
+menu.3d.show3d=In drei-D zeigen
+menu.help=Hilfe
+menu.help.about=Ãœber Prune
+# Popup menu for map
+menu.map.zoomin=Einzoomen
+menu.map.zoomout=Auszoomen
+menu.map.zoomfull=Zoomen zum ganzes Bild
+menu.map.autopan=Autopan
+
+# Dialogs
+dialog.exit.confirm.title=Prune beenden
+dialog.exit.confirm.text=Ihre Daten wurden nicht gespeichert. Wollen Sie trotzdem das Programm beenden?
+dialog.openappend.title=Daten anhängen oder ersetzen
+dialog.openappend.text=Häng diese Daten zu den aktuellen Daten an?
+dialog.deleteduplicates.title=Duplikate löschen
+dialog.deleteduplicates.single.text=Duplikat wurde gelöscht
+dialog.deleteduplicates.multi.text=Duplikate wurden gelöscht
+dialog.deleteduplicates.nonefound=Keine Duplikate gefunden
+dialog.compresstrack.title=Track komprimieren
+dialog.compresstrack.parameter.text=Parameter für Komprimierung (niedriger Nummer = höher Komprimierung)
+dialog.compresstrack.text=Track komprimiert
+dialog.compresstrack.single.text=Punkt wurde entfernt
+dialog.compresstrack.multi.text=Punkte wurden entfernt
+dialog.compresstrack.nonefound=Keine Punkte konnten entfernt werden
+dialog.openoptions.title=Öffnen Optionen
+dialog.openoptions.filesnippet=Extrakt vom File
+dialog.load.table.field=Feld
+dialog.load.table.datatype=Daten Typ
+dialog.load.table.description=Beschreibung
+dialog.delimiter.label=Feld Trennzeichen
+dialog.delimiter.comma=Komma ,
+dialog.delimiter.tab=Tab
+dialog.delimiter.space=Abstand
+dialog.delimiter.semicolon=Strichpunkt ;
+dialog.delimiter.other=Andere
+dialog.openoptions.deliminfo.records=Rekords, mit 
+dialog.openoptions.deliminfo.fields=Feldern
+dialog.openoptions.deliminfo.norecords=Keine Rekords
+dialog.openoptions.tabledesc=Extrakt vom File
+dialog.openoptions.altitudeunits=Höhe Maßeinheiten
+dialog.saveoptions.title=File speichern
+dialog.save.fieldstosave=Felder zu speichern
+dialog.save.table.field=Feld
+dialog.save.table.hasdata=Hat Daten
+dialog.save.table.save=Speichern
+dialog.save.coordinateunits=Koordinaten Maßeinheiten
+dialog.save.units.original=Original
+dialog.save.altitudeunits=Höhe Maßeinheiten
+dialog.save.oktitle=File gespeichert
+dialog.save.ok1=Es wurden
+dialog.save.ok2=Punkte gespeichert nach
+dialog.exportkml.title=KML exportieren
+dialog.exportkml.text=Kurze Beschreibung von den Daten
+dialog.confirmreversetrack.title=Umkehrung bestätigen
+dialog.confirmreversetrack.text=Diese Daten enthalten Zeitstempel Informationen, die bei einer Umkehrung ausser Reihenfolge erscheinen würden.\nSind Sie sicher, Sie wollen diese Spanne umkehren?
+dialog.interpolate.title=Punkte interpolieren
+dialog.interpolate.parameter.text=Anzahl Punkte zuzufügen zwischen den selektierten Punkten
+dialog.undo.title=Undo Operation(en)
+dialog.undo.pretext=Selektieren die Operationen die rückgängig gemacht werden sollen.
+dialog.confirmundo.title=Operation(en) rückgängig gemacht
+dialog.confirmundo.single.text=Operation rückgängig gemacht.
+dialog.confirmundo.multiple.text=Operationen rückgängig gemacht.
+dialog.undo.none.title=Undo nicht möglich
+dialog.undo.none.text=Keine Operationen können rückgängig gemacht werden.
+dialog.clearundo.title=Undo-Liste löschen
+dialog.clearundo.text=Sind Sie sicher, Sie wollen die Undo-Liste löschen?\nAlle Undo Information wird veloren gehen!
+dialog.about.title=Ãœber
+dialog.about.version=Version
+dialog.about.build=Build
+dialog.about.summarytext1=Prune ist ein Programm für das Laden, Darstellen und Editieren von Daten von GPS Geräten.
+dialog.about.summarytext2=Es ist unter den Gnu GPL zur Verfügung gestellt, für frei, gratis und offen Gebrauch und Weiterentwicklung.<br>Kopieren, Weiterverbreitung und Veränderungen sind erlaubt und willkommen<br>unter die Bedingungen im enthaltenen <code>license.txt</code> File.
+dialog.about.summarytext3=Bitte sehen Sie <code style="font-weight:bold">http://activityworkshop.net/</code> für weitere Information und Benutzeranleitungen.
+
+# Buttons
+button.ok=OK
+button.back=Zurück
+button.next=Vorwärts
+button.finish=Fertig
+button.cancel=Abbrechen
+button.moveup=Aufwärts moven
+button.movedown=Abwärts moven
+button.startrange=Start setzen
+button.endrange=Stopp setzen
+button.deletepoint=Punkt löschen
+button.deleterange=Spanne löschen
+button.exit=Beenden
+
+# Display components
+display.nodata=Keine Daten geladen
+display.noaltitudes=Track enthält keine Höhe Daten
+details.trackdetails=Details vom Track
+details.notrack=Kein Track geladen
+details.track.points=Punkte
+details.track.file=Datei
+details.track.numfiles=Anzahl Dateien
+details.pointdetails=Details vom Punkt
+details.index.selected=Index
+details.index.of=von
+details.nopointselection=Nichts selektiert
+details.norangeselection=Nichts selektiert
+details.rangedetails=Details von Spanne
+details.range.selected=Selektiert
+details.range.to=bis
+details.altitude.to=bis
+details.range.climb=Aufstieg
+details.range.descent=Abstieg
+details.distanceunits=Distanz Maßeinheiten
+display.range.time.secs=S
+display.range.time.mins=M
+display.range.time.hours=Std
+display.range.time.days=T
+
+# Field names
+fieldname.latitude=Breitengrad
+fieldname.longitude=Längengrad
+fieldname.altitude=Höhe
+fieldname.timestamp=Zeitstempel
+fieldname.waypointname=Name
+fieldname.waypointtype=Typ
+fieldname.newsegment=Segment
+fieldname.custom=Custom
+fieldname.prefix=Feld
+fieldname.distance=Länge
+fieldname.duration=Zeitlänge
+
+# Measurement units
+units.metres=Meter
+units.metres.short=M
+units.feet=Füße
+units.feet.short=F
+units.kilometres=Kilometer
+units.kilometres.short=Km
+units.miles=Meilen
+units.miles.short=Mei
+units.degminsec=Grad-Min-Sek
+units.degmin=Grad-Min
+units.deg=Grad
+
+# Undo operations
+undo.load=Daten laden
+undo.deletepoint=Punkt löschen
+undo.deleterange=Spanne löschen
+undo.compress=Track komprimieren
+undo.insert=Punkte dazufügen
+undo.deleteduplicates=Duplikaten löschen
+undo.reverse=Spanne umdrehen
+undo.rearrangewaypoints=Waypoints reorganisieren
+
+# Error messages
+error.save.dialogtitle=Fehler beim Speichern
+error.save.nodata=Keine Daten wurden geladen
+error.save.fileexists=File existiert schon.  Um sicher zu sein, wird dieses Programm den File nicht Ã¼berschreiben.
+error.save.failed=Speichern vom File fehlgeschlagen :
+error.load.dialogtitle=Fehler beim Laden
+error.load.noread=File konnte nicht gelesen werden
+error.undofailed.title=Undo fehlgeschlagen
+error.undofailed.text=Operation konnte nicht rückgängig gemacht werden
+error.function.noop.title=Funktion hat nichts gemacht
+error.rearrange.noop=Waypoints Reorganisieren hatte keinen Effekt
+error.function.notimplemented.title=Funktion nicht verfügbar
+error.function.notimplemented=Sorry, diese Funktion wurde noch nicht implementiert.
diff --git a/tim/prune/lang/prune-texts_de_CH.properties b/tim/prune/lang/prune-texts_de_CH.properties
new file mode 100644 (file)
index 0000000..dd7bc9f
--- /dev/null
@@ -0,0 +1,187 @@
+# Text entries for the Prune application
+# Swiss-German entries as extra
+
+# Menu entries
+menu.file=Datei
+menu.file.open=Öffne
+menu.file.save=Speichere
+menu.file.export=KML exportiere
+menu.file.exit=Beände
+menu.edit=Editiere
+menu.edit.undo=Undo
+menu.edit.clearundo=Undo-Liste lösche
+menu.edit.deletepoint=Punkt lösche
+menu.edit.deleterange=Spanne lösche
+menu.edit.deleteduplicates=Doppeldate lösche
+menu.edit.compress=Date komprimiere
+menu.edit.interpolate=Interpoliere
+menu.edit.reverse=Spanne umkehre
+menu.edit.rearrange=Waypoints reorganisiere
+menu.edit.rearrange.start=Alli zum Aafang
+menu.edit.rearrange.end=Alli zum Ã„nde
+menu.edit.rearrange.nearest=Jede zum nöchsti Trackpunkt
+menu.select=Selektiere
+menu.select.all=Alles selektiere
+menu.select.none=Nüüt selektiere
+menu.3d=Drüü-D
+menu.3d.show3d=In drüü-D zeigä
+menu.help=Hilfe
+menu.help.about=Ãœber Prune
+# Popup menu for map
+menu.map.zoomin=Einzoome
+menu.map.zoomout=Uuszoome
+menu.map.zoomfull=Zoome zum ganzes Bild
+menu.map.autopan=Autopan
+
+# Dialogs
+dialog.exit.confirm.title=Prune beände
+dialog.exit.confirm.text=Ihri Date sind nonig gspeicheret worde. Wend Sie trotzdem s Programm beände?
+dialog.openappend.title=Date aahänge oder ersätze
+dialog.openappend.text=Häng diese Date zur aktuelli Daten aa?
+dialog.deleteduplicates.title=Duplikaten lösche
+dialog.deleteduplicates.single.text=Duplikat isch glöscht worde
+dialog.deleteduplicates.multi.text=Duplikaten sin glöscht worde
+dialog.deleteduplicates.nonefound=Keine Duplikaten gefunden
+dialog.compresstrack.title=Track komprimiere
+dialog.compresstrack.parameter.text=Parameter für Komprimierig (niedriger Nummer = höher Komprimierig)
+dialog.compresstrack.text=Track komprimiert worde
+dialog.compresstrack.single.text=Punkt isch entfernt worde
+dialog.compresstrack.multi.text=Punkte sin entfernt worde
+dialog.compresstrack.nonefound=Kei Punkte hätte gelöscht werde könne
+dialog.openoptions.title=Öffne Optionen
+dialog.openoptions.filesnippet=Extrakt vom File
+dialog.load.table.field=Fäld
+dialog.load.table.datatype=Date Typ
+dialog.load.table.description=Beschriibig
+dialog.delimiter.label=Fäld Trennzeiche
+dialog.delimiter.comma=Komma ,
+dialog.delimiter.tab=Tab
+dialog.delimiter.space=Abstand
+dialog.delimiter.semicolon=Strichpunkt ;
+dialog.delimiter.other=Andere
+dialog.openoptions.deliminfo.records=Rekords, mit 
+dialog.openoptions.deliminfo.fields=Fäldere
+dialog.openoptions.deliminfo.norecords=Kei Rekords
+dialog.openoptions.tabledesc=Extrakt vom File
+dialog.openoptions.altitudeunits=Höchi Massiiheite
+dialog.saveoptions.title=File speichere
+dialog.save.fieldstosave=Fälder zu speichere
+dialog.save.table.field=Fäld
+dialog.save.table.hasdata=Het Date
+dialog.save.table.save=Speichere
+dialog.save.coordinateunits=Koordinate Massiiheite
+dialog.save.units.original=Original
+dialog.save.altitudeunits=Höchi Massiiheite
+dialog.save.oktitle=File gespeichert worde
+dialog.save.ok1=Es isch
+dialog.save.ok2=Punkte gespeichert worde na
+dialog.exportkml.title=KML exportiere
+dialog.exportkml.text=Kurze Beschriibig von den Date
+dialog.confirmreversetrack.title=Umdrehig bestätige
+dialog.confirmreversetrack.text=Diese Daten enthalte Ziitstämpel Informatione, die bei dr Umkehrig usser Reihefolge erschiene würdi.\nSind Sie sicher, Sie wend diese Spanne umkehre?
+dialog.interpolate.title=Punkte interpoliere
+dialog.interpolate.parameter.text=Aazahl Punkte zum innätue zwüschet den selektierten Punkten
+dialog.undo.title=Undo Operation(e)
+dialog.undo.pretext=Selektiere die Operatione die rückgängig gmacht söllti werde.
+dialog.confirmundo.title=Operation(e) rückgängig gmacht worde
+dialog.confirmundo.single.text=Operation rückgängig gmacht worde.
+dialog.confirmundo.multiple.text=Operatione rückgängig gmacht worde.
+dialog.undo.none.title=Undo nöd möglich
+dialog.undo.none.text=Keini Operatione könne rückgängig gmacht werde.
+dialog.clearundo.title=Undo-Liste lösche
+dialog.clearundo.text=Sind Sie sicher, Sie wend die Undo-Liste lösche?\nAlle Undo Infos werdet verlore gah!
+dialog.about.title=Ãœber
+dialog.about.version=Version
+dialog.about.build=Build
+dialog.about.summarytext1=Prune isch n Programm fürs Lade, Darstelle und Editiere vo Date von GPS Geräte.
+dialog.about.summarytext2=Es isch unter den Gnu GPL zur Verfüegig gstellt,für frei, gratis und offen Gebruuch und Wiiterentwicklig.<br>Kopiere, Wiiterverbreitig und Veränderige sin erlaubt und willkommen<br>unter die Bedingunge im enthaltene <code>license.txt</code> File.
+dialog.about.summarytext3=Bitte lueg na <code style="font-weight:bold">http://activityworkshop.net/</code> für wiitere Information und Benutzeraaleitige.
+
+# Buttons
+button.ok=OK
+button.back=Zrugg
+button.next=Nöchste
+button.finish=Fertig
+button.cancel=Abbräche
+button.moveup=Uufwärts move
+button.movedown=Abwärts move
+button.startrange=Start setze
+button.endrange=Stopp setze
+button.deletepoint=Punkt lösche
+button.deleterange=Spanne lösche
+button.exit=Beände
+
+# Display components
+display.nodata=Kei Date glade worde
+display.noaltitudes=Track hät kei Höhi Date
+details.trackdetails=Details vom Track
+details.notrack=Kei Track glade worde
+details.track.points=Punkte
+details.track.file=Datei
+details.track.numfiles=Anzahl Dateie
+details.pointdetails=Details vom Punkt
+details.index.selected=Index
+details.index.of=vo
+details.nopointselection=Nüüt selektiert
+details.norangeselection=Nüüt selektiert
+details.rangedetails=Details vo dr Spanne
+details.range.selected=Selektiert
+details.range.to=bis
+details.altitude.to=bis
+details.range.climb=Uufstieg
+details.range.descent=Abstieg
+details.distanceunits=Distanz Masseinheiten
+display.range.time.secs=S
+display.range.time.mins=M
+display.range.time.hours=Std
+display.range.time.days=T
+
+# Field names
+fieldname.latitude=Breitegrad
+fieldname.longitude=Längegrad
+fieldname.altitude=Höchi
+fieldname.timestamp=Ziitstämpel
+fieldname.waypointname=Name
+fieldname.waypointtype=Typ
+fieldname.newsegment=Segmänt
+fieldname.custom=Custom
+fieldname.prefix=Fäld
+fieldname.distance=Längi
+fieldname.duration=Ziitlängi
+
+# Measurement units
+units.metres=Meter
+units.metres.short=M
+units.feet=Fuess
+units.feet.short=F
+units.kilometres=Kilometer
+units.kilometres.short=Km
+units.miles=Meile
+units.miles.short=Mei
+units.degminsec=Grad-Min-Sek
+units.degmin=Grad-Min
+units.deg=Grad
+
+# Undo operations
+undo.load=Date lade
+undo.deletepoint=Punkt lösche
+undo.deleterange=Spanne lösche
+undo.compress=Track komprimiere
+undo.insert=Punkte innätue
+undo.deleteduplicates=Duplikaten lösche
+undo.reverse=Spanne umdrähie
+undo.rearrangewaypoints=Waypoints reorganisiere
+
+# Error messages
+error.save.dialogtitle=Fehler bim Speichere
+error.save.nodata=Kei Date zum speichere
+error.save.fileexists=File existiert scho.  Um sicher z'sii, wird s'Programm s'File nöd Ã¼berschriibe.
+error.save.failed=Speichere vom File fehlgschlage :
+error.load.dialogtitle=Fehler bim Lade
+error.load.noread=File cha nöd glase werde
+error.undofailed.title=Undo isch fehlgschlage
+error.undofailed.text=Operation kann nöd rückgängig gmacht werde
+error.function.noop.title=Funktion hät gar nüüt gmacht
+error.rearrange.noop=Waypoints Reorganisiere hät kein Effäkt gha
+error.function.notimplemented.title=Funktion nöd verfüegbar
+error.function.notimplemented=Sorry, d'Funktion isch nonig implementiert worde.
diff --git a/tim/prune/license.txt b/tim/prune/license.txt
new file mode 100644 (file)
index 0000000..d511905
--- /dev/null
@@ -0,0 +1,339 @@
+                   GNU GENERAL PUBLIC LICENSE
+                      Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                           Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                   GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                           NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                    END OF TERMS AND CONDITIONS
+
+           How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/tim/prune/load/DelimiterInfo.java b/tim/prune/load/DelimiterInfo.java
new file mode 100644 (file)
index 0000000..d882972
--- /dev/null
@@ -0,0 +1,63 @@
+package tim.prune.load;
+
+/**
+ * Class to hold information about the contents of a file
+ * given a delimiter character
+ */
+public class DelimiterInfo
+{
+       private char _delimiter = '\0';
+       private int _numRecords = 0;
+       private int _numWinningRecords = 0;
+       private int _maxFields = 0;
+
+
+       /**
+        * Constructor
+        * @param inChar delimiter character
+        */
+       public DelimiterInfo(char inChar)
+       {
+               _delimiter = inChar;
+       }
+
+       public char getDelimiter()
+       {
+               return _delimiter;
+       }
+
+       public int getMaxFields()
+       {
+               return _maxFields;
+       }
+
+       public void updateMaxFields(int inNumields)
+       {
+               if (inNumields > _maxFields)
+                       _maxFields = inNumields;
+       }
+
+
+       public int getNumRecords()
+       {
+               return _numRecords;
+       }
+       public void incrementNumRecords()
+       {
+               _numRecords++;
+       }
+
+       public int getNumWinningRecords()
+       {
+               return _numWinningRecords;
+       }
+       public void incrementNumWinningRecords()
+       {
+               _numWinningRecords++;
+       }
+
+       public String toString()
+       {
+               return "(delim:" + _delimiter + " fields:" + _maxFields + ", records:" + _numRecords + ")";
+       }
+}
diff --git a/tim/prune/load/FieldSelectionTableModel.java b/tim/prune/load/FieldSelectionTableModel.java
new file mode 100644 (file)
index 0000000..11b60d9
--- /dev/null
@@ -0,0 +1,240 @@
+package tim.prune.load;
+
+import javax.swing.table.AbstractTableModel;
+
+import tim.prune.I18nManager;
+import tim.prune.data.Field;
+
+/**
+ * Class to hold the table model for the field selection table
+ */
+public class FieldSelectionTableModel extends AbstractTableModel
+{
+
+       private int _numRows = 0;
+       private Field[] _fieldArray = null;
+       private String _customText = null;
+
+       /**
+        * Constructor
+        */
+       public FieldSelectionTableModel()
+       {
+               // Cache the custom text for the table so it doesn't
+               // have to be looked up so often
+               _customText = I18nManager.getText("fieldname.custom");
+       }
+
+
+       /**
+        * Get the column count
+        */
+       public int getColumnCount()
+       {
+               return 3;
+       }
+
+
+       /**
+        * Get the name of the column
+        */
+       public String getColumnName(int inColNum)
+       {
+               if (inColNum == 0) return I18nManager.getText("dialog.load.table.field");
+               else if (inColNum == 1) return I18nManager.getText("dialog.load.table.datatype");
+               return I18nManager.getText("dialog.load.table.description");
+       }
+
+
+       /**
+        * Get the row count
+        */
+       public int getRowCount()
+       {
+               if (_fieldArray == null)
+                       return 2;
+               return _numRows;
+       }
+
+
+       /**
+        * Get the value of the specified cell
+        */
+       public Object getValueAt(int rowIndex, int columnIndex)
+       {
+               if (_fieldArray == null) return "";
+               if (columnIndex == 0) return ("" + (rowIndex+1));
+               Field field = _fieldArray[rowIndex];
+               if (columnIndex == 1)
+               {
+                       // Field name - take name from built-in fields
+                       if (field.isBuiltIn())
+                               return field.getName();
+                       // Otherwise take custom name
+                       return _customText;
+               }
+               // description column - builtin fields don't have one
+               if (field.isBuiltIn()) return "";
+               return field.getName();
+       }
+
+
+       /**
+        * Make sure only second and third columns are editable
+        */
+       public boolean isCellEditable(int rowIndex, int columnIndex)
+       {
+               if (columnIndex <= 1)
+                       return (columnIndex == 1);
+               // Column is 2 so only edit non-builtin field names
+               Field field = _fieldArray[rowIndex];
+               return !field.isBuiltIn();
+       }
+
+
+       /**
+        * Update the data
+        * @param inData 2-dimensional Object array containing the data
+        */
+       public void updateData(Field[] inData)
+       {
+               _fieldArray = inData;
+               if (_fieldArray != null)
+               {
+                       _numRows = _fieldArray.length;
+               }
+               fireTableStructureChanged();
+       }
+
+
+       /**
+        * React to edits to the table data
+        */
+       public void setValueAt(Object aValue, int rowIndex, int columnIndex)
+       {
+               super.setValueAt(aValue, rowIndex, columnIndex);
+               if (columnIndex == 1)
+               {
+                       Field field = _fieldArray[rowIndex];
+                       if (!field.getName().equals(aValue.toString()))
+                       {
+                               manageFieldChange(rowIndex, aValue.toString());
+                       }
+               }
+               else if (columnIndex == 2)
+               {
+                       // change description if it's custom
+                       Field field = _fieldArray[rowIndex];
+                       if (!field.isBuiltIn())
+                               field.setName(aValue.toString());
+               }
+       }
+
+
+       /**
+        * Move the selected item up one place
+        * @param inIndex index of item to move
+        */
+       public void moveUp(int inIndex)
+       {
+               if (inIndex > 0)
+               {
+                       swapItems(inIndex-1, inIndex);
+               }
+       }
+
+
+       /**
+        * Move the selected item down one place
+        * @param inIndex index of item to move
+        */
+       public void moveDown(int inIndex)
+       {
+               if (inIndex > -1 && inIndex < (_numRows - 1))
+               {
+                       swapItems(inIndex, inIndex+1);
+               }
+       }
+
+
+       /**
+        * Swap the specified items in the array
+        * @param inIndex1 index of first item
+        * @param inIndex2 index of second item (higher than inIndex1)
+        */
+       private void swapItems(int inIndex1, int inIndex2)
+       {
+               Field temp = _fieldArray[inIndex1];
+               _fieldArray[inIndex1] = _fieldArray[inIndex2];
+               _fieldArray[inIndex2] = temp;
+               fireTableRowsUpdated(inIndex1, inIndex2);
+       }
+
+
+       /**
+        * React to a requested change to one of the fields
+        * @param inRow row number of change
+        * @param inValue new string value
+        */
+       private void manageFieldChange(int inRow, String inValue)
+       {
+               // check if it's lat or long - don't allow changes to these fields
+               Field field = _fieldArray[inRow];
+               if (field == Field.LATITUDE || field == Field.LONGITUDE)
+                       return;
+               if (inValue.equals(I18nManager.getText("fieldname.latitude"))
+                 || inValue.equals(I18nManager.getText("fieldname.longitude")))
+                       return;
+
+               // Changes to custom field need to be handled differently
+               boolean changeToCustom = inValue.equals(I18nManager.getText("fieldname.custom"));
+               if (changeToCustom)
+               {
+                       if (field.isBuiltIn())
+                       {
+                               String customPrefix = I18nManager.getText("fieldname.prefix") + " ";
+                               int index = inRow + 1;
+                               while (hasField(customPrefix + index))
+                                       index++;
+                               _fieldArray[inRow] = new Field(customPrefix + index);
+                       }
+                       // ignore custom to custom changes
+               }
+               else
+               {
+                       // Change to a fixed field - check we've not already got it
+                       if (!hasField(inValue))
+                       {
+                               // Change is ok - find new Field object corresponding to text
+                               for (int i=0; i<Field.ALL_AVAILABLE_FIELDS.length; i++)
+                                       if (Field.ALL_AVAILABLE_FIELDS[i].getName().equals(inValue))
+                                               _fieldArray[inRow] = Field.ALL_AVAILABLE_FIELDS[i];
+                       }
+               }
+               // fire change
+               fireTableRowsUpdated(inRow, inRow);
+       }
+
+
+       /**
+        * @return array of Field objects
+        */
+       public Field[] getFieldArray()
+       {
+               return _fieldArray;
+       }
+
+
+       /**
+        * @param inName Name of field to find
+        * @return true if this field is already present
+        */
+       private boolean hasField(String inName)
+       {
+               if (_fieldArray == null || inName == null) return false;
+               for (int i=0; i<_numRows; i++)
+                       if (_fieldArray[i].getName().equals(inName))
+                               return true;
+               return false;
+       }
+}
diff --git a/tim/prune/load/FileCacher.java b/tim/prune/load/FileCacher.java
new file mode 100644 (file)
index 0000000..733754b
--- /dev/null
@@ -0,0 +1,125 @@
+package tim.prune.load;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.ArrayList;
+
+/**
+ * Class to load the contents of a file
+ * into an array for later retrieval
+ */
+public class FileCacher
+{
+       private File _file = null;
+       private String[] _contentArray = null;
+
+
+       /**
+        * Constructor
+        * @param inFile File object to cache
+        */
+       public FileCacher(File inFile)
+       {
+               _file = inFile;
+               loadFile();
+       }
+
+
+       /**
+        * Load the specified file into memory
+        */
+       private void loadFile()
+       {
+               ArrayList contentList = new ArrayList();
+               if (_file != null && _file.exists() && _file.canRead())
+               {
+                       BufferedReader reader = null;
+                       try
+                       {
+                               reader = new BufferedReader(new FileReader(_file));
+                               String currLine = reader.readLine();
+                               while (currLine != null)
+                               {
+                                       if (currLine.trim().length() > 0)
+                                               contentList.add(currLine);
+                                       currLine = reader.readLine();
+                               }
+                       }
+                       catch (IOException ioe) {}
+                       finally
+                       {
+                               // close file ignoring errors
+                               try
+                               {
+                                       if (reader != null) reader.close();
+                               }
+                               catch (Exception e) {}
+                       }
+               }
+               // Convert into String array for keeps
+               int numLines = contentList.size();
+               _contentArray = new String[numLines];
+               for (int i=0; i<numLines; i++)
+                       _contentArray[i] = contentList.get(i).toString();
+       }
+
+
+       /**
+        * @return Contents of the file as array of non-blank Strings
+        */
+       public String[] getContents()
+       {
+               return _contentArray;
+       }
+
+
+       /**
+        * Get the top section of the file for preview
+        * @param inSize number of lines to extract
+        * @return String array containing non-blank lines from the file
+        */
+       public String[] getSnippet(int inNumRows, int inMaxWidth)
+       {
+               final int MIN_SNIPPET_SIZE = 3;
+               // Check size is within sensible limits
+               int numToCopy = inNumRows;
+               if (numToCopy > getNumLines()) numToCopy = getNumLines();
+               int size = numToCopy;
+               if (size < MIN_SNIPPET_SIZE) size = MIN_SNIPPET_SIZE;
+               String[] result = new String[size];
+               // Copy Strings across
+               System.arraycopy(_contentArray, 0, result, 0, numToCopy);
+               // Chop Strings to max width if necessary
+               if (inMaxWidth > 10)
+               {
+                       for (int i=0; i<size; i++)
+                       {
+                               if (result[i].length() > inMaxWidth)
+                                       result[i] = result[i].trim();
+                               if (result[i].length() > inMaxWidth)
+                                       result[i] = result[i].substring(0, inMaxWidth);
+                       }
+               }
+               return result;
+       }
+
+       /**
+        * @return the number of non-blank lines in the file
+        */
+       public int getNumLines()
+       {
+               return _contentArray.length;
+       }
+
+
+       /**
+        * Clear the memory
+        */
+       public void clear()
+       {
+               _file = null;
+               _contentArray = null;
+       }
+}
diff --git a/tim/prune/load/FileExtractTableModel.java b/tim/prune/load/FileExtractTableModel.java
new file mode 100644 (file)
index 0000000..42f39b3
--- /dev/null
@@ -0,0 +1,81 @@
+package tim.prune.load;
+
+import javax.swing.table.AbstractTableModel;
+
+/**
+ * Class to hold the table model for the file extract table
+ */
+public class FileExtractTableModel extends AbstractTableModel
+{
+
+       private int _numRows = 0;
+       private Object[][] _tableData = null;
+
+       /**
+        * Get the column count
+        */
+       public int getColumnCount()
+       {
+               if (_tableData == null)
+                       return 2;
+               return _tableData[0].length;
+       }
+
+       /**
+        * Get the name of the column, in this case just the number
+        */
+       public String getColumnName(int inColNum)
+       {
+               return "" + (inColNum + 1);
+       }
+
+       /**
+        * Get the row count
+        */
+       public int getRowCount()
+       {
+               if (_tableData == null)
+                       return 2;
+               return _numRows;
+       }
+
+       /**
+        * Get the value of the specified cell
+        */
+       public Object getValueAt(int rowIndex, int columnIndex)
+       {
+               if (_tableData == null) return "";
+               return _tableData[rowIndex][columnIndex];
+       }
+
+       /**
+        * Make sure table data is not editable
+        */
+       public boolean isCellEditable(int rowIndex, int columnIndex)
+       {
+               return false;
+       }
+
+       /**
+        * Update the data
+        * @param inData 2-dimensional Object array containing the data
+        */
+       public void updateData(Object[][] inData)
+       {
+               _tableData = inData;
+               if (_tableData != null)
+               {
+                       _numRows = _tableData.length;
+               }
+               fireTableStructureChanged();
+       }
+
+
+       /**
+        * @return Object array of data
+        */
+       public Object[][] getData()
+       {
+               return _tableData;
+       }
+}
diff --git a/tim/prune/load/FileLoader.java b/tim/prune/load/FileLoader.java
new file mode 100644 (file)
index 0000000..c7126f5
--- /dev/null
@@ -0,0 +1,563 @@
+package tim.prune.load;
+
+import java.awt.BorderLayout;
+import java.awt.CardLayout;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import javax.swing.*;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import javax.swing.event.ListSelectionEvent;
+import javax.swing.event.ListSelectionListener;
+import javax.swing.table.TableCellEditor;
+
+import java.io.File;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.Altitude;
+import tim.prune.data.Field;
+
+
+/**
+ * Special class to handle file loading including GUI options,
+ * and conversion to a Track object
+ */
+public class FileLoader
+{
+       private File _file = null;
+       private App _app = null;
+       private JFrame _parentFrame = null;
+       private JDialog _dialog = null;
+       private JPanel _cardPanel = null;
+       private CardLayout _layout = null;
+       private JButton _backButton = null, _nextButton = null;
+       private JButton _finishButton = null;
+       private JButton _moveUpButton = null, _moveDownButton = null;
+       private JRadioButton[] _delimiterRadios = null;
+       private JTextField _otherDelimiterText = null;
+       private JLabel _statusLabel = null;
+       private DelimiterInfo[] _delimiterInfos = null;
+       private JFileChooser _fileChooser = null;
+       private FileCacher _fileCacher = null;
+       private JList _snippetBox = null;
+       private FileExtractTableModel _fileExtractTableModel = null;
+       private JTable _fieldTable;
+       private FieldSelectionTableModel _fieldTableModel = null;
+       private JComboBox _unitsDropDown = null;
+       private int _selectedField = -1;
+       private char _currentDelimiter = ',';
+
+       // previously selected values
+       private char _lastUsedDelimiter = ',';
+       private int _lastNumFields = -1;
+       private Field[] _lastSelectedFields = null;
+       private int _lastAltitudeFormat = Altitude.FORMAT_NONE;
+
+       // constants
+       private static final int SNIPPET_SIZE = 6;
+       private static final int MAX_SNIPPET_WIDTH = 80;
+       private static final char[] DELIMITERS = {',', '\t', ';', ' '};
+
+
+       /**
+        * Inner class to listen for delimiter change operations
+        */
+       private class DelimListener implements ActionListener, DocumentListener
+       {
+               public void actionPerformed(ActionEvent e)
+               {
+                       informDelimiterSelected();
+               }
+               public void changedUpdate(DocumentEvent e)
+               {
+                       informDelimiterSelected();
+               }
+               public void insertUpdate(DocumentEvent e)
+               {
+                       informDelimiterSelected();
+               }
+               public void removeUpdate(DocumentEvent e)
+               {
+                       informDelimiterSelected();
+               }
+       }
+
+
+       /**
+        * Constructor
+        * @param inApp Application object to inform of track load
+        * @param inParentFrame parent frame to reference for dialogs
+        */
+       public FileLoader(App inApp, JFrame inParentFrame)
+       {
+               _app = inApp;
+               _parentFrame = inParentFrame;
+       }
+
+
+       /**
+        * Select an input file and open the GUI frame
+        * to select load options
+        */
+       public void openFile()
+       {
+               if (_fileChooser == null)
+                       _fileChooser = new JFileChooser();
+               if (_fileChooser.showOpenDialog(_parentFrame) == JFileChooser.APPROVE_OPTION)
+               {
+                       _file = _fileChooser.getSelectedFile();
+                       if (preCheckFile(_file))
+                       {
+                               _dialog = new JDialog(_parentFrame, I18nManager.getText("dialog.openoptions.title"), true);
+                               _dialog.setLocationRelativeTo(_parentFrame);
+                               _dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
+                               _dialog.getContentPane().add(makeDialogComponents());
+
+                               // select best separator according to row counts (more is better)
+                               int bestDelim = getBestOption(_delimiterInfos[0].getNumWinningRecords(),
+                                       _delimiterInfos[1].getNumWinningRecords(), _delimiterInfos[2].getNumWinningRecords(),
+                                       _delimiterInfos[3].getNumWinningRecords());
+                               if (bestDelim >= 0)
+                                       _delimiterRadios[bestDelim].setSelected(true);
+                               else
+                                       _delimiterRadios[_delimiterRadios.length-1].setSelected(true);
+                               informDelimiterSelected();
+                               _dialog.pack();
+                               _dialog.show();
+                       }
+                       else
+                       {
+                               JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("error.load.noread"),
+                                       I18nManager.getText("error.load.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+                       }
+               }
+       }
+
+
+       /**
+        * Check the given file for readability and funny characters,
+        * and count the fields for the various separators
+        * @param inFile file to check
+        */
+       private boolean preCheckFile(File inFile)
+       {
+               // Check file exists and is readable
+               if (inFile == null || !inFile.exists() || !inFile.canRead())
+               {
+                       return false;
+               }
+               // Use a FileCacher to read the file into an array
+               _fileCacher = new FileCacher(inFile);
+
+               // Check each line of the file
+               String[] fileContents = _fileCacher.getContents();
+               boolean fileOK = true;
+               _delimiterInfos = new DelimiterInfo[5];
+               for (int i=0; i<4; i++) _delimiterInfos[i] = new DelimiterInfo(DELIMITERS[i]);
+
+               String currLine = null;
+               String[] splitFields = null;
+               int commaFields = 0, semicolonFields = 0, tabFields = 0, spaceFields = 0;
+               for (int lineNum=0; lineNum<fileContents.length && fileOK; lineNum++)
+               {
+                       currLine = fileContents[lineNum];
+                       // check for invalid characters
+                       if (currLine.indexOf('\0') >= 0) {fileOK = false;}
+                       // check for commas
+                       splitFields = currLine.split(",");
+                       commaFields = splitFields.length;
+                       if (commaFields > 1) _delimiterInfos[0].incrementNumRecords();
+                       _delimiterInfos[0].updateMaxFields(commaFields);
+                       // check for tabs
+                       splitFields = currLine.split("\t");
+                       tabFields = splitFields.length;
+                       if (tabFields > 1) _delimiterInfos[1].incrementNumRecords();
+                       _delimiterInfos[1].updateMaxFields(tabFields);
+                       // check for semicolons
+                       splitFields = currLine.split(";");
+                       semicolonFields = splitFields.length;
+                       if (semicolonFields > 1) _delimiterInfos[2].incrementNumRecords();
+                       _delimiterInfos[2].updateMaxFields(semicolonFields);
+                       // check for spaces
+                       splitFields = currLine.split(" ");
+                       spaceFields = splitFields.length;
+                       if (spaceFields > 1) _delimiterInfos[3].incrementNumRecords();
+                       _delimiterInfos[3].updateMaxFields(spaceFields);
+                       // increment counters
+                       int bestScorer = getBestOption(commaFields, tabFields, semicolonFields, spaceFields);
+                       if (bestScorer >= 0)
+                               _delimiterInfos[bestScorer].incrementNumWinningRecords();
+               }
+               return fileOK;
+       }
+
+
+       /**
+        * Get the index of the best one in the list
+        * @return the index of the maximum of the four given values
+        */
+       private static int getBestOption(int inOpt0, int inOpt1, int inOpt2, int inOpt3)
+       {
+               int bestIndex = -1;
+               int maxScore = 1;
+               if (inOpt0 > maxScore) {bestIndex = 0; maxScore = inOpt0;}
+               if (inOpt1 > maxScore) {bestIndex = 1; maxScore = inOpt1;}
+               if (inOpt2 > maxScore) {bestIndex = 2; maxScore = inOpt2;}
+               if (inOpt3 > maxScore) {bestIndex = 3; maxScore = inOpt3;}
+               return bestIndex;
+       }
+
+
+       /**
+        * Make the components for the open options dialog
+        * @return Component for all options
+        */
+       private Component makeDialogComponents()
+       {
+               JPanel wholePanel = new JPanel();
+               wholePanel.setLayout(new BorderLayout());
+
+               // add buttons to south
+               JPanel buttonPanel = new JPanel();
+               buttonPanel.setLayout(new FlowLayout(FlowLayout.CENTER));
+               _backButton = new JButton(I18nManager.getText("button.back"));
+               _backButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _layout.previous(_cardPanel);
+                               _backButton.setEnabled(false);
+                               _nextButton.setEnabled(true);
+                               _finishButton.setEnabled(false);
+                       }
+               });
+               _backButton.setEnabled(false);
+               buttonPanel.add(_backButton);
+               _nextButton = new JButton(I18nManager.getText("button.next"));
+               _nextButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               prepareSecondPanel();
+                               _layout.next(_cardPanel);
+                               _nextButton.setEnabled(false);
+                               _backButton.setEnabled(true);
+                               _finishButton.setEnabled(true);
+                       }
+               });
+               buttonPanel.add(_nextButton);
+               _finishButton = new JButton(I18nManager.getText("button.finish"));
+               _finishButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               finished();
+                       }
+               });
+               _finishButton.setEnabled(false);
+               buttonPanel.add(_finishButton);
+               JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
+               cancelButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _dialog.dispose();
+                       }
+               });
+               buttonPanel.add(cancelButton);
+               wholePanel.add(buttonPanel, BorderLayout.SOUTH);
+
+               // Make the two cards, for delimiter and fields
+               _cardPanel = new JPanel();
+               _layout = new CardLayout();
+               _cardPanel.setLayout(_layout);
+               JPanel firstCard = new JPanel();
+               firstCard.setLayout(new BorderLayout());
+
+               JPanel delimsPanel = new JPanel();
+               delimsPanel.setLayout(new GridLayout(0, 2));
+               delimsPanel.add(new JLabel(I18nManager.getText("dialog.delimiter.label")));
+               delimsPanel.add(new JLabel("")); // blank label to go to next grid row
+               // radio buttons
+               _delimiterRadios = new JRadioButton[5];
+               _delimiterRadios[0] = new JRadioButton(I18nManager.getText("dialog.delimiter.comma"));
+               delimsPanel.add(_delimiterRadios[0]);
+               _delimiterRadios[1] = new JRadioButton(I18nManager.getText("dialog.delimiter.tab"));
+               delimsPanel.add(_delimiterRadios[1]);
+               _delimiterRadios[2] = new JRadioButton(I18nManager.getText("dialog.delimiter.semicolon"));
+               delimsPanel.add(_delimiterRadios[2]);
+               _delimiterRadios[3] = new JRadioButton(I18nManager.getText("dialog.delimiter.space"));
+               delimsPanel.add(_delimiterRadios[3]);
+               JPanel otherPanel = new JPanel();
+               otherPanel.setLayout(new FlowLayout(FlowLayout.LEFT, 0, 0));
+               _delimiterRadios[4] = new JRadioButton(I18nManager.getText("dialog.delimiter.other"));
+               otherPanel.add(_delimiterRadios[4]);
+               _otherDelimiterText = new JTextField(new OneCharDocument(), null, 2);
+               otherPanel.add(_otherDelimiterText);
+               // Group radio buttons
+               ButtonGroup delimGroup = new ButtonGroup();
+               DelimListener delimListener = new DelimListener();
+               for (int i=0; i<_delimiterRadios.length; i++)
+               {
+                       delimGroup.add(_delimiterRadios[i]);
+                       _delimiterRadios[i].addActionListener(delimListener);
+               }
+               _otherDelimiterText.getDocument().addDocumentListener(delimListener);
+               delimsPanel.add(new JLabel(""));
+               delimsPanel.add(otherPanel);
+               _statusLabel = new JLabel("");
+               delimsPanel.add(_statusLabel);
+               firstCard.add(delimsPanel, BorderLayout.SOUTH);
+               // load snippet to show first few lines
+               _snippetBox = new JList(_fileCacher.getSnippet(SNIPPET_SIZE, MAX_SNIPPET_WIDTH));
+               _snippetBox.setEnabled(false);
+               firstCard.add(makeLabelledPanel("dialog.openoptions.filesnippet", _snippetBox), BorderLayout.CENTER);
+
+               // Second screen, for field order selection
+               JPanel secondCard = new JPanel();
+               secondCard.setLayout(new BorderLayout());
+               // table for file contents
+               _fileExtractTableModel = new FileExtractTableModel();
+               JTable extractTable = new JTable(_fileExtractTableModel);
+               JScrollPane tableScrollPane = new JScrollPane(extractTable);
+               extractTable.setPreferredScrollableViewportSize(new Dimension(350, 80));
+               extractTable.getTableHeader().setReorderingAllowed(false);
+               secondCard.add(makeLabelledPanel("dialog.openoptions.tabledesc", tableScrollPane), BorderLayout.NORTH);
+               JPanel innerPanel2 = new JPanel();
+               innerPanel2.setLayout(new BorderLayout());
+               _fieldTable = new JTable(new FieldSelectionTableModel());
+               _fieldTable.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+               // add listener for selected table row
+               _fieldTable.getSelectionModel().addListSelectionListener(
+                       new ListSelectionListener() {
+                               public void valueChanged(ListSelectionEvent e) {
+                                       ListSelectionModel lsm = (ListSelectionModel) e.getSource();
+                                       if (lsm.isSelectionEmpty()) {
+                                               //no rows are selected
+                                               selectField(-1);
+                                       } else {
+                                               selectField(lsm.getMinSelectionIndex());
+                                       }
+                               }
+                       });
+               JPanel tablePanel = new JPanel();
+               tablePanel.setLayout(new BorderLayout());
+               tablePanel.add(_fieldTable.getTableHeader(), BorderLayout.NORTH);
+               tablePanel.add(_fieldTable, BorderLayout.CENTER);
+               innerPanel2.add(tablePanel, BorderLayout.CENTER);
+
+               JPanel innerPanel3 = new JPanel();
+               innerPanel3.setLayout(new BoxLayout(innerPanel3, BoxLayout.Y_AXIS));
+               _moveUpButton = new JButton(I18nManager.getText("button.moveup"));
+               _moveUpButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               int currRow = _fieldTable.getSelectedRow();
+                               closeTableComboBox(currRow);
+                               _fieldTableModel.moveUp(currRow);
+                               _fieldTable.setRowSelectionInterval(currRow-1, currRow-1);
+                       }
+               });
+               innerPanel3.add(_moveUpButton);
+               _moveDownButton = new JButton(I18nManager.getText("button.movedown"));
+               _moveDownButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               int currRow = _fieldTable.getSelectedRow();
+                               closeTableComboBox(currRow);
+                               _fieldTableModel.moveDown(currRow);
+                               _fieldTable.setRowSelectionInterval(currRow+1, currRow+1);
+                       }
+               });
+               innerPanel3.add(_moveDownButton);
+               innerPanel3.add(Box.createVerticalStrut(70));
+
+               innerPanel2.add(innerPanel3, BorderLayout.EAST);
+               secondCard.add(innerPanel2, BorderLayout.CENTER);
+               JPanel altUnitsPanel = new JPanel();
+               altUnitsPanel.setLayout(new FlowLayout(FlowLayout.LEFT));
+               altUnitsPanel.add(new JLabel(I18nManager.getText("dialog.openoptions.altitudeunits")));
+               String[] units = {I18nManager.getText("units.metres"), I18nManager.getText("units.feet")};
+               _unitsDropDown = new JComboBox(units);
+               altUnitsPanel.add(_unitsDropDown);
+               secondCard.add(altUnitsPanel, BorderLayout.SOUTH);
+               _cardPanel.add(firstCard, "card1");
+               _cardPanel.add(secondCard, "card2");
+
+               wholePanel.add(_cardPanel, BorderLayout.CENTER);
+               return wholePanel;
+       }
+
+
+       /**
+        * Close the combo box on the selected row of the field table
+        * @param inRow currently selected row number
+        */
+       private void closeTableComboBox(int inRow)
+       {
+               TableCellEditor editor = _fieldTable.getCellEditor(inRow, 1);
+               if (editor != null)
+               {
+                       editor.stopCellEditing();
+               }
+       }
+
+
+       /**
+        * change the status based on selection of a delimiter
+        */
+       protected void informDelimiterSelected()
+       {
+               for (int i=0; i<(_delimiterRadios.length-1); i++)
+               {
+                       if (_delimiterRadios[i].isSelected())
+                       {
+                               int numRecords = _delimiterInfos[i].getNumRecords();
+                               if (numRecords == 0)
+                               {
+                                       _statusLabel.setText(I18nManager.getText("dialog.openoptions.deliminfo.norecords"));
+                               }
+                               else
+                               {
+                                       _statusLabel.setText("" + numRecords + " " + I18nManager.getText("dialog.openoptions.deliminfo.records")
+                                               + _delimiterInfos[i].getMaxFields() + " " + I18nManager.getText("dialog.openoptions.deliminfo.fields"));
+                               }
+                       }
+               }
+               if (_delimiterRadios[_delimiterRadios.length-1].isSelected())
+               {
+                       _statusLabel.setText("");
+               }
+               // enable/disable next button
+               _nextButton.setEnabled(_delimiterRadios[4].isSelected() == false
+                       || _otherDelimiterText.getText().length() == 1);
+       }
+
+
+       /**
+        * Get the delimiter info from the first step
+        * @return delimiter information object for the selected delimiter
+        */
+       public DelimiterInfo getSelectedDelimiterInfo()
+       {
+               for (int i=0; i<4; i++)
+                       if (_delimiterRadios[i].isSelected()) return _delimiterInfos[i];
+               // must be "other" - build info if necessary
+               if (_delimiterInfos[4] == null)
+                       _delimiterInfos[4] = new DelimiterInfo(_otherDelimiterText.getText().charAt(0));
+               return _delimiterInfos[4];
+       }
+
+
+       /**
+        * Use the delimiter selected to determine the fields in the file
+        * and prepare the second panel accordingly
+        */
+       private void prepareSecondPanel()
+       {
+               DelimiterInfo info = getSelectedDelimiterInfo();
+               FileSplitter splitter = new FileSplitter(_fileCacher);
+               // Check info makes sense - num fields > 0, num records > 0
+               // set "Finished" button to disabled if not ok
+               // TODO: Work out if there are header rows or not, save?
+               // Try to match header rows with fields
+               // Try to match data with fields
+               // Add data to GUI elements
+               Object[][] tableData = splitter.splitFieldData(info.getDelimiter());
+               // possible to ignore blank columns here
+               _currentDelimiter = info.getDelimiter();
+               _fileExtractTableModel.updateData(tableData);
+               _fieldTableModel = new FieldSelectionTableModel();
+
+               // Check number of fields and use last ones if count matches
+               Field[] startFieldArray = null;
+               if (_lastSelectedFields != null && splitter.getNumColumns() == _lastSelectedFields.length)
+                       startFieldArray = _lastSelectedFields;
+               else
+                       startFieldArray = splitter.makeDefaultFields();
+               _fieldTableModel.updateData(startFieldArray);
+               _fieldTable.setModel(_fieldTableModel);
+               // add dropdowns to second column
+               JComboBox fieldTypesBox = new JComboBox();
+               for (int i=0; i<Field.ALL_AVAILABLE_FIELDS.length; i++)
+               {
+                       fieldTypesBox.addItem(Field.ALL_AVAILABLE_FIELDS[i].getName());
+               }
+               _fieldTable.getColumnModel().getColumn(1).setCellEditor(new DefaultCellEditor(fieldTypesBox));
+
+               // Set altitude format to same as last time if available
+               if (_lastAltitudeFormat == Altitude.FORMAT_METRES)
+                       _unitsDropDown.setSelectedIndex(0);
+               else if (_lastAltitudeFormat == Altitude.FORMAT_FEET)
+                       _unitsDropDown.setSelectedIndex(1);
+               // no selection on field list
+               selectField(-1);
+       }
+
+
+       /**
+        * All options have been selected, so load file
+        */
+       private void finished()
+       {
+               // Save delimiter, field array and altitude format for later use
+               _lastUsedDelimiter = _currentDelimiter;
+               _lastSelectedFields = _fieldTableModel.getFieldArray();
+               int altitudeFormat = Altitude.FORMAT_METRES;
+               if (_unitsDropDown.getSelectedIndex() == 1)
+               {
+                       altitudeFormat = Altitude.FORMAT_FEET;
+               }
+               _lastAltitudeFormat = altitudeFormat;
+               // give data to App
+               _app.informDataLoaded(_fieldTableModel.getFieldArray(),
+                       _fileExtractTableModel.getData(), altitudeFormat,
+                       _file.getName());
+               // clear up file cacher
+               _fileCacher.clear();
+               // dispose of dialog
+               _dialog.dispose();
+       }
+
+
+       /**
+        * Make a panel with a label and a component
+        * @param inLabelKey label key to use
+        * @param inComponent component for main area of panel
+        * @return labelled Panel
+        */
+       private static JPanel makeLabelledPanel(String inLabelKey, JComponent inComponent)
+       {
+               JPanel panel = new JPanel();
+               panel.setLayout(new BorderLayout());
+               panel.add(new JLabel(I18nManager.getText(inLabelKey)), BorderLayout.NORTH);
+               panel.add(inComponent, BorderLayout.CENTER);
+               return panel;
+       }
+
+
+       /**
+        * An entry in the field list has been selected
+        * @param inFieldNum index of field, starting with 0
+        */
+       private void selectField(int inFieldNum)
+       {
+               if (inFieldNum == -1 || inFieldNum != _selectedField)
+               {
+                       _selectedField = inFieldNum;
+                       _moveUpButton.setEnabled(inFieldNum > 0);
+                       _moveDownButton.setEnabled(inFieldNum >= 0
+                               && inFieldNum < (_fieldTableModel.getRowCount()-1));
+               }
+       }
+
+
+       /**
+        * @return the last delimiter character used for a load
+        */
+       public char getLastUsedDelimiter()
+       {
+               return _lastUsedDelimiter;
+       }
+}
diff --git a/tim/prune/load/FileSplitter.java b/tim/prune/load/FileSplitter.java
new file mode 100644 (file)
index 0000000..216e027
--- /dev/null
@@ -0,0 +1,147 @@
+package tim.prune.load;
+
+import tim.prune.I18nManager;
+import tim.prune.data.Field;
+
+/**
+ * Class responsible for splitting the file contents into an array
+ * based on the selected delimiter character
+ */
+public class FileSplitter
+{
+       private FileCacher _cacher = null;
+       private int _numRows = 0;
+       private int _numColumns = 0;
+       private boolean[] _columnStates = null;
+
+       /**
+        * Constructor
+        * @param inCacher FileCacher object holding file contents
+        */
+       public FileSplitter(FileCacher inCacher)
+       {
+               _cacher = inCacher;
+       }
+
+       /**
+        * Split the FileCacher's contents into a 2d array
+        * @param inDelim delimiter character
+        * @return 2d Object array
+        */
+       public String[][] splitFieldData(char inDelim)
+       {
+               if (_cacher == null) return null;
+               String[] contents = _cacher.getContents();
+               if (contents == null || contents.length == 0) return null;
+               String delimStr = "" + inDelim;
+               // Count non-blank rows and max field count
+               _numRows = 0;
+               int maxFields = 0;
+               for (int i=0; i<contents.length; i++)
+               {
+                       if (contents[i] != null && !contents[i].trim().equals(""))
+                       {
+                               _numRows++;
+                               String[] splitLine = contents[i].split(delimStr);
+                               if (splitLine != null && splitLine.length > maxFields)
+                               {
+                                       maxFields = splitLine.length;
+                               }
+                       }
+               }
+               _numColumns = maxFields;
+               _columnStates = new boolean[maxFields];
+
+               // Create array and populate it
+               // Note that array will be rectangular even if data is ragged
+               String[][] result = new String[_numRows][];
+               for (int i=0; i<contents.length; i++)
+               {
+                       result[i] = new String[maxFields];
+                       if (contents[i] != null)
+                       {
+                               String wholeLine = contents[i].trim();
+                               if (!wholeLine.equals(""))
+                               {
+                                       String[] splitLine = wholeLine.split(delimStr);
+                                       if (splitLine != null)
+                                       {
+                                               System.arraycopy(splitLine, 0, result[i], 0, splitLine.length);
+                                               // Check if columns are blank or not
+                                               for (int j=0; j<splitLine.length; j++)
+                                               {
+                                                       if (!_columnStates[j] && splitLine[j].trim().length() > 0)
+                                                       {
+                                                               _columnStates[j] = true;
+                                                       }
+                                               }
+                                       }
+                               }
+                       }
+               }
+               return result;
+       }
+
+
+       /**
+        * @return the number of rows in the data
+        */
+       public int getNumRows()
+       {
+               return _numRows;
+       }
+
+
+       /**
+        * @return the number of columns in the data
+        */
+       public int getNumColumns()
+       {
+               return _numColumns;
+       }
+
+
+       /**
+        * Check if the specified column of the data is blank
+        * @param inColumnNum number of column, starting with 0
+        * @return true if no data exists in this column
+        */
+       public boolean isColumnBlank(int inColumnNum)
+       {
+               // Should probably trap out of range values
+               return !_columnStates[inColumnNum];
+       }
+
+
+       /**
+        * @return a Field array to use as defaults for the data
+        */
+       public Field[] makeDefaultFields()
+       {
+               Field[] fields = null;
+               if (_numColumns > 0)
+               {
+                       fields = new Field[_numColumns];
+                       try
+                       {
+                               fields[0] = Field.LATITUDE;
+                               fields[1] = Field.LONGITUDE;
+                               fields[2] = Field.ALTITUDE;
+                               fields[3] = Field.WAYPT_NAME;
+                               fields[4] = Field.WAYPT_TYPE;
+                               String customPrefix = I18nManager.getText("fieldname.prefix") + " ";
+                               for (int i=5;; i++)
+                               {
+                                       fields[i] = new Field(customPrefix + (i+1));
+                               }
+                       }
+                       catch (ArrayIndexOutOfBoundsException finished)
+                       {
+                               // Finished populating array
+                       }
+               }
+               else
+                       fields = new Field[0];
+               return fields;
+       }
+}
diff --git a/tim/prune/load/OneCharDocument.java b/tim/prune/load/OneCharDocument.java
new file mode 100644 (file)
index 0000000..9868984
--- /dev/null
@@ -0,0 +1,21 @@
+package tim.prune.load;
+
+import javax.swing.text.AttributeSet;
+import javax.swing.text.BadLocationException;
+import javax.swing.text.PlainDocument;
+
+/**
+ * Document class to restrict text input to single character
+ * for selection of custom delimiter
+ */
+public class OneCharDocument extends PlainDocument
+{
+       public void insertString(int offs, String str, AttributeSet a)
+               throws BadLocationException
+       {
+               //This rejects the insertion if it would make
+               //the contents too long.
+               if ((getLength() + str.length()) <= 1)
+                       super.insertString(offs, str, a);
+       }
+}
diff --git a/tim/prune/readme.txt b/tim/prune/readme.txt
new file mode 100644 (file)
index 0000000..fc788d2
--- /dev/null
@@ -0,0 +1,37 @@
+Prune version 1
+===============
+
+Prune is an application for viewing, editing and managing coordinate data from GPS systems.
+
+Prune is copyright activityworkshop.net and distributed under the terms of the Gnu GPL version 2.
+You may freely use the software, and may help others to freely use it too.  For further information
+on your rights and how they are protected, see the included license.txt file.
+
+Prune comes without warranty and without guarantee - the authors cannot be held responsible for
+losses incurred through use of the program, however caused.
+
+
+Running
+=======
+
+To run Prune from the jar file, simply call it from a Command Prompt or shell:
+   java -jar prune_1.jar
+
+If the jar file is saved in a different directory, you will need to include the path.
+Depending on your system settings, you may be able to click or double-click on the jar file
+in a file manager window to execute it.  A shortcut, menu item, desktop icon or other link
+can of course be made should you wish.
+
+
+Further information and updates
+===============================
+
+To obtain the source code (if it wasn't included in your jar file), or for further information,
+please visit the website:  http://activityworkshop.net/
+
+You will find there user guides and screenshots illustrating the major features.
+As Prune is further developed, subsequent versions of the program will also be made freely
+available at this website.
+
+You can also provide feedback on Prune, and find out more about contributing to the development,
+especially with regard to language translations.
diff --git a/tim/prune/save/FieldInfo.java b/tim/prune/save/FieldInfo.java
new file mode 100644 (file)
index 0000000..4098547
--- /dev/null
@@ -0,0 +1,69 @@
+package tim.prune.save;
+
+import tim.prune.data.Field;
+
+/**
+ * Class to hold field information for save dialog
+ */
+public class FieldInfo
+{
+       private Field _field = null;
+       private boolean _data = false;
+       private boolean _selected = true;
+
+
+       /**
+        * Constructor
+        */
+       public FieldInfo(Field inField, boolean inData)
+       {
+               _field = inField;
+               _selected = _data = inData;
+       }
+
+
+       /**
+        * @return the field object
+        */
+       public Field getField()
+       {
+               return _field;
+       }
+
+
+       /**
+        * @return true if field has data
+        */
+       public boolean hasData()
+       {
+               return _data;
+       }
+
+
+       /**
+        * @return true if field is selected
+        */
+       public boolean isSelected()
+       {
+               return _selected;
+       }
+
+
+       /**
+        * Set whether the field is selected or not
+        * @param inSelected true to select field
+        */
+       public void setSelected(boolean inSelected)
+       {
+               _selected = inSelected;
+       }
+
+
+       /**
+        * @return String for debug
+        */
+       public String toString()
+       {
+               return _field.getName() + (_data?"(data)":"(no data)") + ", " + (_selected?"(sel)":"(---)");
+       }
+}
diff --git a/tim/prune/save/FieldSelectionTableModel.java b/tim/prune/save/FieldSelectionTableModel.java
new file mode 100644 (file)
index 0000000..92fda1e
--- /dev/null
@@ -0,0 +1,142 @@
+package tim.prune.save;
+
+import javax.swing.table.AbstractTableModel;
+
+import tim.prune.I18nManager;
+
+/**
+ * Class to hold table model information for save dialog
+ */
+public class FieldSelectionTableModel extends AbstractTableModel
+{
+       private FieldInfo[] _info = null;
+
+
+       /**
+        * Constructor giving list size
+        */
+       public FieldSelectionTableModel(int inSize)
+       {
+               _info = new FieldInfo[inSize];
+       }
+
+
+       /**
+        * Set the given FieldInfo object in the array
+        * @param inInfo FieldInfo object describing the field
+        * @param inIndex index to place in array
+        */
+       public void addFieldInfo(FieldInfo inInfo, int inIndex)
+       {
+               _info[inIndex] = inInfo;
+       }
+
+
+       /**
+        * @see javax.swing.table.TableModel#getColumnCount()
+        */
+       public int getColumnCount()
+       {
+               return 3;
+       }
+
+
+       /**
+        * @see javax.swing.table.TableModel#getRowCount()
+        */
+       public int getRowCount()
+       {
+               return _info.length;
+       }
+
+
+       /**
+        * @see javax.swing.table.TableModel#getValueAt(int, int)
+        */
+       public Object getValueAt(int inRowIndex, int inColumnIndex)
+       {
+               if (inColumnIndex == 0)
+               {
+                       return _info[inRowIndex].getField().getName();
+               }
+               else if (inColumnIndex == 1)
+               {
+                       return new Boolean(_info[inRowIndex].hasData());
+               }
+               return new Boolean(_info[inRowIndex].isSelected());
+       }
+
+
+       /**
+        * @return true if cell is editable
+        */
+       public boolean isCellEditable(int inRowIndex, int inColumnIndex)
+       {
+               // only the select column is editable
+               return inColumnIndex == 2;
+       }
+
+
+       /**
+        * Set the given cell value
+        * @see javax.swing.table.TableModel#setValueAt(java.lang.Object, int, int)
+        */
+       public void setValueAt(Object inValue, int inRowIndex, int inColumnIndex)
+       {
+               // ignore edits to other columns
+               if (inColumnIndex == 2)
+                       _info[inRowIndex].setSelected(((Boolean) inValue).booleanValue());
+       }
+
+
+       /**
+        * Swap the specified items in the array
+        * @param inIndex1 first index
+        * @param inIndex2 second index
+        */
+       public void swapItems(int inIndex1, int inIndex2)
+       {
+               if (inIndex1 >= 0 && inIndex1 < _info.length && inIndex2 >= 0 && inIndex2 < _info.length)
+               {
+                       FieldInfo temp = _info[inIndex1];
+                       _info[inIndex1] = _info[inIndex2];
+                       _info[inIndex2] = temp;
+               }
+       }
+
+
+       /**
+        * @return Class of cell data
+        */
+       public Class getColumnClass(int inColumnIndex)
+       {
+               if (inColumnIndex==0) return String.class;
+               return Boolean.class;
+       }
+
+
+       /**
+        * Get the name of the column
+        */
+       public String getColumnName(int inColNum)
+       {
+               if (inColNum == 0) return I18nManager.getText("dialog.save.table.field");
+               else if (inColNum == 1) return I18nManager.getText("dialog.save.table.hasdata");
+               return I18nManager.getText("dialog.save.table.save");
+       }
+
+
+       /**
+        * Retrieve the FieldInfo object at the given index
+        * @param inIndex index, starting at 0
+        * @return FieldInfo object at this position
+        */
+       public FieldInfo getFieldInfo(int inIndex)
+       {
+               if (inIndex < 0 || inIndex >= _info.length)
+               {
+                       return null;
+               }
+               return _info[inIndex];
+       }
+}
diff --git a/tim/prune/save/FileSaver.java b/tim/prune/save/FileSaver.java
new file mode 100644 (file)
index 0000000..b1ea368
--- /dev/null
@@ -0,0 +1,461 @@
+package tim.prune.save;
+
+import java.awt.BorderLayout;
+import java.awt.CardLayout;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.GridLayout;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.ButtonGroup;
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JRadioButton;
+import javax.swing.JTable;
+import javax.swing.JTextField;
+import javax.swing.ListSelectionModel;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.Altitude;
+import tim.prune.data.Coordinate;
+import tim.prune.data.DataPoint;
+import tim.prune.data.Field;
+import tim.prune.data.FieldList;
+import tim.prune.data.Track;
+import tim.prune.load.OneCharDocument;
+
+/**
+ * Class to manage the saving of track data
+ * into a user-specified file
+ */
+public class FileSaver
+{
+       private App _app = null;
+       private JFrame _parentFrame = null;
+       private Track _track = null;
+       private JDialog _dialog = null;
+       private JFileChooser _fileChooser = null;
+       private JPanel _cards = null;
+       private JButton _nextButton = null, _backButton = null;
+       private JTable _table = null;
+       private FieldSelectionTableModel _model = null;
+       private JButton _moveUpButton = null, _moveDownButton = null;
+       private JRadioButton[] _delimiterRadios = null;
+       private JTextField _otherDelimiterText = null;
+       private JRadioButton[] _coordUnitsRadios = null;
+       private JRadioButton[] _altitudeUnitsRadios = null;
+       private static final int[] FORMAT_COORDS = {Coordinate.FORMAT_NONE, Coordinate.FORMAT_DEG_MIN_SEC,
+               Coordinate.FORMAT_DEG_MIN, Coordinate.FORMAT_DEG};
+       private static final int[] FORMAT_ALTS = {Altitude.FORMAT_NONE, Altitude.FORMAT_METRES, Altitude.FORMAT_FEET};
+
+
+       /**
+        * Constructor
+        * @param inApp application object to inform of success
+        * @param inParentFrame parent frame
+        * @param inTrack track object to save
+        */
+       public FileSaver(App inApp, JFrame inParentFrame, Track inTrack)
+       {
+               _app = inApp;
+               _parentFrame = inParentFrame;
+               _track = inTrack;
+       }
+
+
+       /**
+        * Show the save file dialog
+        * @param inDefaultDelimiter default delimiter to use
+        */
+       public void showDialog(char inDefaultDelimiter)
+       {
+               _dialog = new JDialog(_parentFrame, I18nManager.getText("dialog.saveoptions.title"), true);
+               _dialog.setLocationRelativeTo(_parentFrame);
+               // Check field list
+               FieldList fieldList = _track.getFieldList();
+               int numFields = fieldList.getNumFields();
+               _model = new FieldSelectionTableModel(numFields);
+               for (int i=0; i<numFields; i++)
+               {
+                       Field field = fieldList.getField(i);
+                       FieldInfo info = new FieldInfo(field, _track.hasData(field));
+                       _model.addFieldInfo(info, i);
+               }
+               _dialog.getContentPane().add(makeDialogComponents(_model, inDefaultDelimiter));
+               _dialog.pack();
+               _dialog.show();
+       }
+
+
+       /**
+        * Make the dialog components
+        * @param inTableModel table model for fields
+        * @param inDelimiter default delimiter character
+        * @return the GUI components for the save dialog
+        */
+       private Component makeDialogComponents(FieldSelectionTableModel inTableModel, char inDelimiter)
+       {
+               JPanel panel = new JPanel();
+               panel.setLayout(new BorderLayout());
+               _cards = new JPanel();
+               _cards.setLayout(new CardLayout());
+               panel.add(_cards, BorderLayout.CENTER);
+
+               // Make first card for field selection and delimiter
+               JPanel firstCard = new JPanel();
+               firstCard.setLayout(new BoxLayout(firstCard, BoxLayout.Y_AXIS));
+               JPanel tablePanel = new JPanel();
+               tablePanel.setLayout(new BorderLayout());
+               _table = new JTable(inTableModel);
+               _table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
+               tablePanel.add(_table.getTableHeader(), BorderLayout.NORTH);
+               tablePanel.add(_table, BorderLayout.CENTER);
+
+               // Make a panel to hold the table and up/down buttons
+               JPanel fieldsPanel = new JPanel();
+               fieldsPanel.setLayout(new BorderLayout());
+               fieldsPanel.add(tablePanel, BorderLayout.CENTER);
+               JPanel updownPanel = new JPanel();
+               updownPanel.setLayout(new BoxLayout(updownPanel, BoxLayout.Y_AXIS));
+               _moveUpButton = new JButton(I18nManager.getText("button.moveup"));
+               _moveUpButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               int row = _table.getSelectedRow();
+                               if (row > 0)
+                               {
+                                       _model.swapItems(row, row - 1);
+                                       _table.setRowSelectionInterval(row - 1, row - 1);
+                               }
+                       }
+               });
+               _moveUpButton.setEnabled(false);
+               updownPanel.add(_moveUpButton);
+               _moveDownButton = new JButton(I18nManager.getText("button.movedown"));
+               _moveDownButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               int row = _table.getSelectedRow();
+                               if (row > -1 && row < (_model.getRowCount() - 1))
+                               {
+                                       _model.swapItems(row, row + 1);
+                                       _table.setRowSelectionInterval(row + 1, row + 1);
+                               }
+                       }
+               });
+               _moveDownButton.setEnabled(false);
+               updownPanel.add(_moveDownButton);
+               fieldsPanel.add(updownPanel, BorderLayout.EAST);
+               // enable/disable buttons based on table row selection
+               _table.getSelectionModel().addListSelectionListener(
+                       new UpDownToggler(_moveUpButton, _moveDownButton, inTableModel.getRowCount())
+               );
+
+               // Add fields panel and the delimiter panel to first card in pack
+               JLabel saveOptionsLabel = new JLabel(I18nManager.getText("dialog.save.fieldstosave"));
+               saveOptionsLabel.setAlignmentX(JLabel.LEFT_ALIGNMENT);
+               firstCard.add(saveOptionsLabel);
+               fieldsPanel.setAlignmentX(JPanel.LEFT_ALIGNMENT);
+               firstCard.add(fieldsPanel);
+               firstCard.add(Box.createRigidArea(new Dimension(0,10)));
+
+               // delimiter panel
+               JLabel delimLabel = new JLabel(I18nManager.getText("dialog.delimiter.label"));
+               delimLabel.setAlignmentX(JLabel.LEFT_ALIGNMENT);
+               firstCard.add(delimLabel);
+               JPanel delimsPanel = new JPanel();
+               delimsPanel.setLayout(new GridLayout(0, 2));
+               delimsPanel.setAlignmentX(JPanel.LEFT_ALIGNMENT);
+               // radio buttons
+               _delimiterRadios = new JRadioButton[5];
+               _delimiterRadios[0] = new JRadioButton(I18nManager.getText("dialog.delimiter.comma"));
+               delimsPanel.add(_delimiterRadios[0]);
+               _delimiterRadios[1] = new JRadioButton(I18nManager.getText("dialog.delimiter.tab"));
+               delimsPanel.add(_delimiterRadios[1]);
+               _delimiterRadios[2] = new JRadioButton(I18nManager.getText("dialog.delimiter.semicolon"));
+               delimsPanel.add(_delimiterRadios[2]);
+               _delimiterRadios[3] = new JRadioButton(I18nManager.getText("dialog.delimiter.space"));
+               delimsPanel.add(_delimiterRadios[3]);
+               JPanel otherPanel = new JPanel();
+               otherPanel.setLayout(new FlowLayout(FlowLayout.LEFT, 0, 0));
+               _delimiterRadios[4] = new JRadioButton(I18nManager.getText("dialog.delimiter.other"));
+               otherPanel.add(_delimiterRadios[4]);
+               _otherDelimiterText = new JTextField(new OneCharDocument(), null, 2);
+               otherPanel.add(_otherDelimiterText);
+               // Group radio buttons
+               ButtonGroup delimGroup = new ButtonGroup();
+               for (int i=0; i<_delimiterRadios.length; i++)
+               {
+                       delimGroup.add(_delimiterRadios[i]);
+               }
+               // choose last-used delimiter as default
+               switch (inDelimiter)
+               {
+                       case ','  : _delimiterRadios[0].setSelected(true); break;
+                       case '\t' : _delimiterRadios[1].setSelected(true); break;
+                       case ';'  : _delimiterRadios[2].setSelected(true); break;
+                       case ' '  : _delimiterRadios[3].setSelected(true); break;
+                       default   : _delimiterRadios[4].setSelected(true);
+                                               _otherDelimiterText.setText("" + inDelimiter);
+               }
+               delimsPanel.add(otherPanel);
+               firstCard.add(delimsPanel);
+               _cards.add(firstCard, "card1");
+
+               JPanel secondCard = new JPanel();
+               secondCard.setLayout(new BorderLayout());
+               JPanel secondCardHolder = new JPanel();
+               secondCardHolder.setLayout(new BoxLayout(secondCardHolder, BoxLayout.Y_AXIS));
+               JLabel coordLabel = new JLabel(I18nManager.getText("dialog.save.coordinateunits"));
+               coordLabel.setAlignmentX(JLabel.LEFT_ALIGNMENT);
+               secondCardHolder.add(coordLabel);
+               JPanel coordsUnitsPanel = new JPanel();
+               coordsUnitsPanel.setBorder(BorderFactory.createEtchedBorder());
+               coordsUnitsPanel.setLayout(new GridLayout(0, 2));
+               _coordUnitsRadios = new JRadioButton[4];
+               _coordUnitsRadios[0] = new JRadioButton(I18nManager.getText("dialog.save.units.original"));
+               _coordUnitsRadios[1] = new JRadioButton(I18nManager.getText("units.degminsec"));
+               _coordUnitsRadios[2] = new JRadioButton(I18nManager.getText("units.degmin"));
+               _coordUnitsRadios[3] = new JRadioButton(I18nManager.getText("units.deg"));
+               ButtonGroup coordGroup = new ButtonGroup();
+               for (int i=0; i<4; i++)
+               {
+                       coordGroup.add(_coordUnitsRadios[i]);
+                       coordsUnitsPanel.add(_coordUnitsRadios[i]);
+                       _coordUnitsRadios[i].setSelected(i==0);
+               }
+               coordsUnitsPanel.setAlignmentX(JPanel.LEFT_ALIGNMENT);
+               secondCardHolder.add(coordsUnitsPanel);
+               secondCardHolder.add(Box.createRigidArea(new Dimension(0,10)));
+               JLabel altUnitsLabel = new JLabel(I18nManager.getText("dialog.save.altitudeunits"));
+               altUnitsLabel.setAlignmentX(JLabel.LEFT_ALIGNMENT);
+               secondCardHolder.add(altUnitsLabel);
+               JPanel altUnitsPanel = new JPanel();
+               altUnitsPanel.setBorder(BorderFactory.createEtchedBorder());
+               altUnitsPanel.setLayout(new GridLayout(0, 2));
+               _altitudeUnitsRadios = new JRadioButton[3];
+               _altitudeUnitsRadios[0] = new JRadioButton(I18nManager.getText("dialog.save.units.original"));
+               _altitudeUnitsRadios[1] = new JRadioButton(I18nManager.getText("units.metres"));
+               _altitudeUnitsRadios[2] = new JRadioButton(I18nManager.getText("units.feet"));
+               ButtonGroup altGroup = new ButtonGroup();
+               for (int i=0; i<3; i++)
+               {
+                       altGroup.add(_altitudeUnitsRadios[i]);
+                       altUnitsPanel.add(_altitudeUnitsRadios[i]);
+                       _altitudeUnitsRadios[i].setSelected(i==0);
+               }
+               altUnitsPanel.setAlignmentX(JPanel.LEFT_ALIGNMENT);
+               secondCardHolder.add(altUnitsPanel);
+               secondCard.add(secondCardHolder, BorderLayout.NORTH);
+               _cards.add(secondCard, "card2");
+
+               // Put together with ok/cancel buttons on the bottom
+               JPanel buttonPanel = new JPanel();
+               buttonPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
+               _backButton = new JButton(I18nManager.getText("button.back"));
+               _backButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                           CardLayout cl = (CardLayout)(_cards.getLayout());
+                           cl.previous(_cards);
+                           _backButton.setEnabled(false);
+                           _nextButton.setEnabled(true);
+                       }
+               });
+               _backButton.setEnabled(false);
+               buttonPanel.add(_backButton);
+               _nextButton = new JButton(I18nManager.getText("button.next"));
+               _nextButton.setEnabled(true);
+               _nextButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                           CardLayout cl = (CardLayout)(_cards.getLayout());
+                           cl.next(_cards);
+                           _backButton.setEnabled(true);
+                           _nextButton.setEnabled(false);
+                       }
+               });
+               buttonPanel.add(_nextButton);
+               JButton okButton = new JButton(I18nManager.getText("button.finish"));
+               okButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               if (saveToFile())
+                               {
+                                       _dialog.dispose();
+                               }
+                       }
+               });
+               buttonPanel.add(okButton);
+               JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
+               cancelButton.addActionListener(new ActionListener() {
+                       public void actionPerformed(ActionEvent e)
+                       {
+                               _dialog.dispose();
+                       }
+               });
+               buttonPanel.add(cancelButton);
+               panel.add(buttonPanel, BorderLayout.SOUTH);
+               return panel;
+       }
+
+
+       /**
+        * Save the track to file with the chosen options
+        * @return true if successful or cancelled, false if failed
+        */
+       private boolean saveToFile()
+       {
+               boolean saveOK = true;
+               FileWriter writer = null;
+               if (_fileChooser == null)
+                       _fileChooser = new JFileChooser();
+               _fileChooser.setDialogType(JFileChooser.SAVE_DIALOG);
+               if (_fileChooser.showSaveDialog(_parentFrame) == JFileChooser.APPROVE_OPTION)
+               {
+                       File saveFile = _fileChooser.getSelectedFile();
+                       String lineSeparator = System.getProperty("line.separator");
+                       // Get coordinate format and altitude format
+                       int coordFormat = Coordinate.FORMAT_NONE;
+                       for (int i=0; i<_coordUnitsRadios.length; i++)
+                               if (_coordUnitsRadios[i].isSelected())
+                                       coordFormat = FORMAT_COORDS[i];
+                       int altitudeFormat = Altitude.FORMAT_NONE;
+                       for (int i=0; i<_altitudeUnitsRadios.length; i++)
+                               if (_altitudeUnitsRadios[i].isSelected())
+                                       altitudeFormat = FORMAT_ALTS[i];
+                       
+                       // Check if file exists, don't overwrite any files for v1!
+                       if (!saveFile.exists())
+                       {
+                               try
+                               {
+                                       // Create output file
+                                       writer = new FileWriter(saveFile);
+                                       // Determine delimiter character to use
+                                       char delimiter = getDelimiter();
+                                       FieldInfo info = null;
+                                       Field field = null;
+                                       StringBuffer buffer = null;
+                                       // For now, just spit out to console
+                                       int numPoints = _track.getNumPoints();
+                                       int numFields = _model.getRowCount();
+                                       for (int p=0; p<numPoints; p++)
+                                       {
+                                               DataPoint point = _track.getPoint(p);
+                                               boolean firstField = true;
+                                               buffer = new StringBuffer();
+                                               for (int f=0; f<numFields; f++)
+                                               {
+                                                       info = _model.getFieldInfo(f);
+                                                       if (info.isSelected())
+                                                       {
+                                                               if (!firstField)
+                                                               {
+                                                                       // output field separator
+                                                                       buffer.append(delimiter);
+                                                               }
+                                                               field = info.getField();
+                                                               // Output field according to type
+                                                               if (field == Field.LATITUDE)
+                                                               {
+                                                                       buffer.append(point.getLatitude().output(coordFormat));
+                                                               }
+                                                               else if (field == Field.LONGITUDE)
+                                                               {
+                                                                       buffer.append(point.getLongitude().output(coordFormat));
+                                                               }
+                                                               else if (field == Field.ALTITUDE)
+                                                               {
+                                                                       buffer.append(point.getAltitude().getValue(altitudeFormat));
+                                                               }
+                                                               else if (field == Field.TIMESTAMP)
+                                                               {
+                                                                       buffer.append(point.getTimestamp().getText());
+                                                               }
+                                                               else
+                                                               {
+                                                                       String value = point.getFieldValue(field);
+                                                                       if (value != null)
+                                                                       {
+                                                                               buffer.append(value);
+                                                                       }
+                                                               }
+                                                               firstField = false;
+                                                       }
+                                               }
+                                               // Output to file
+                                               writer.write(buffer.toString());
+                                               writer.write(lineSeparator);
+                                       }
+                                       // Save successful
+                                       JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("dialog.save.ok1")
+                                                + " " + numPoints + " " + I18nManager.getText("dialog.save.ok2")
+                                                + saveFile.getAbsolutePath(),
+                                               I18nManager.getText("dialog.save.oktitle"), JOptionPane.INFORMATION_MESSAGE);
+                                       _app.informDataSaved();
+                               }
+                               catch (IOException ioe)
+                               {
+                                       saveOK = false;
+                                       JOptionPane.showMessageDialog(_parentFrame,
+                                               I18nManager.getText("error.save.failed") + ioe.getMessage(),
+                                               I18nManager.getText("error.save.dialogtitle"),
+                                               JOptionPane.ERROR_MESSAGE);
+                               }
+                               finally
+                               {
+                                       // try to close file if it's open
+                                       try
+                                       {
+                                               if (writer != null)
+                                               {
+                                                       writer.close();
+                                               }
+                                       }
+                                       catch (Exception e) {}
+                               }
+                       }
+                       else
+                       {
+                               saveOK = false;
+                               JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("error.save.fileexists"),
+                                       I18nManager.getText("error.save.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+                       }
+               }
+               return saveOK;
+       }
+
+
+       /**
+        * @return the selected delimiter character
+        */
+       private char getDelimiter()
+       {
+               // Check the preset 4 delimiters
+               final char[] delimiters = {',', '\t', ';', ' '};
+               for (int i=0; i<4; i++)
+               {
+                       if (_delimiterRadios[i].isSelected())
+                       {
+                               return delimiters[i];
+                       }
+               }
+               // Wasn't any of those so must be 'other'
+               return _otherDelimiterText.getText().charAt(0);
+       }
+}
diff --git a/tim/prune/save/KmlExporter.java b/tim/prune/save/KmlExporter.java
new file mode 100644 (file)
index 0000000..6c95b4c
--- /dev/null
@@ -0,0 +1,207 @@
+package tim.prune.save;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.Writer;
+
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JOptionPane;
+import javax.swing.filechooser.FileFilter;
+
+import tim.prune.App;
+import tim.prune.I18nManager;
+import tim.prune.data.Coordinate;
+import tim.prune.data.DataPoint;
+import tim.prune.data.Field;
+import tim.prune.data.Track;
+
+/**
+ * Class to export track information
+ * into a specified Kml file
+ */
+public class KmlExporter
+{
+       private App _app = null;
+       private JFrame _parentFrame = null;
+       private Track _track = null;
+       private JFileChooser _fileChooser = null;
+
+
+       /**
+        * Constructor giving App object, frame and track
+        * @param inApp application object to inform of success
+        * @param inParentFrame parent frame
+        * @param inTrack track object to save
+        */
+       public KmlExporter(App inApp, JFrame inParentFrame, Track inTrack)
+       {
+               _app = inApp;
+               _parentFrame = inParentFrame;
+               _track = inTrack;
+       }
+
+
+       /**
+        * Show the dialog to select options and export file
+        */
+       public boolean showDialog()
+       {
+               boolean fileSaved = false;
+               Object description = JOptionPane.showInputDialog(_parentFrame,
+                       I18nManager.getText("dialog.exportkml.text"),
+                       I18nManager.getText("dialog.exportkml.title"),
+                       JOptionPane.QUESTION_MESSAGE, null, null, "");
+               if (description != null)
+               {
+                       // OK pressed, so choose output file
+                       if (_fileChooser == null)
+                               _fileChooser = new JFileChooser();
+                       _fileChooser.setDialogType(JFileChooser.SAVE_DIALOG);
+                       _fileChooser.setFileFilter(new FileFilter() {
+                               public boolean accept(File f)
+                               {
+                                       return (f != null && (f.isDirectory() || f.getName().toLowerCase().endsWith(".kml")));
+                               }
+                               public String getDescription()
+                               {
+                                       return "KML files";
+                               }
+                       });
+                       _fileChooser.setAcceptAllFileFilterUsed(false);
+                       // Allow choose again if an existing file is selected
+                       boolean chooseAgain = false;
+                       do
+                       {
+                               chooseAgain = false;
+                               if (_fileChooser.showSaveDialog(_parentFrame) == JFileChooser.APPROVE_OPTION)
+                               {
+                                       // OK pressed and file chosen
+                                       File file = _fileChooser.getSelectedFile();
+                                       if (!file.getName().toLowerCase().endsWith(".kml"))
+                                       {
+                                               file = new File(file.getAbsolutePath() + ".kml");
+                                       }
+                                       // Check if file exists - if so don't overwrite
+                                       if (file.exists())
+                                       {
+                                               JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("error.save.fileexists"),
+                                                       I18nManager.getText("error.save.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+                                               chooseAgain = true;
+                                       }
+                                       else
+                                       {
+                                               if (exportFile(file, description.toString()))
+                                               {
+                                                       fileSaved = true;
+                                               }
+                                               else
+                                               {
+                                                       chooseAgain = true;
+                                               }
+                                       }
+                               }
+                       } while (chooseAgain);
+               }
+               return fileSaved;
+       }
+
+
+       /**
+        * Export the track data to the specified file with description
+        * @param inFile File object to save to
+        * @param inDescription description to use, if any
+        */
+       private boolean exportFile(File inFile, String inDescription)
+       {
+               try
+               {
+                       FileWriter writer = new FileWriter(inFile);
+                       writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<kml xmlns=\"http://earth.google.com/kml/2.1\">\n<Folder>\n");
+                       writer.write("\t<name>");
+                       writer.write(inDescription);
+                       writer.write("</name>\n");
+
+                       int i = 0;
+                       DataPoint point = null;
+                       boolean hasTrackpoints = false;
+                       // Loop over waypoints
+                       int numPoints = _track.getNumPoints();
+                       for (i=0; i<numPoints; i++)
+                       {
+                               point = _track.getPoint(i);
+                               if (point.isWaypoint())
+                               {
+                                       exportWaypoint(point, writer);
+                               }
+                               else
+                               {
+                                       hasTrackpoints = true;
+                               }
+                       }
+                       if (hasTrackpoints)
+                       {
+                               writer.write("\t<Placemark>\n\t\t<name>track</name>\n\t\t<Style>\n\t\t\t<LineStyle>\n"
+                                       + "\t\t\t\t<color>cc0000cc</color>\n\t\t\t\t<width>4</width>\n\t\t\t</LineStyle>\n"
+                                       + "\t\t</Style>\n\t\t<LineString>\n\t\t\t<coordinates>");
+                               // Loop over track points
+                               for (i=0; i<numPoints; i++)
+                               {
+                                       point = _track.getPoint(i);
+                                       if (!point.isWaypoint())
+                                       {
+                                               exportTrackpoint(point, writer);
+                                       }
+                               }
+                               writer.write("\t\t\t</coordinates>\n\t\t</LineString>\n\t</Placemark>");
+                       }
+                       writer.write("</Folder>\n</kml>");
+                       writer.close();
+                       JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("dialog.save.ok1")
+                                + " " + numPoints + " " + I18nManager.getText("dialog.save.ok2")
+                                + inFile.getAbsolutePath(),
+                               I18nManager.getText("dialog.save.oktitle"), JOptionPane.INFORMATION_MESSAGE);
+                       return true;
+               }
+               catch (IOException ioe)
+               {
+                       JOptionPane.showMessageDialog(_parentFrame, I18nManager.getText("error.save.failed") + ioe.getMessage(),
+                               I18nManager.getText("error.save.dialogtitle"), JOptionPane.ERROR_MESSAGE);
+               }
+               return false;
+       }
+
+
+       /**
+        * Export the specified waypoint into the file
+        * @param inPoint waypoint to export
+        * @param inWriter writer object
+        */
+       private void exportWaypoint(DataPoint inPoint, Writer inWriter) throws IOException
+       {
+               inWriter.write("\t<Placemark>\n\t\t<name>");
+               inWriter.write(inPoint.getFieldValue(Field.WAYPT_NAME).trim());
+               inWriter.write("</name>\n");
+               inWriter.write("\t\t<Point>\n\t\t\t<coordinates>");
+               inWriter.write(inPoint.getLongitude().output(Coordinate.FORMAT_DEG_WITHOUT_CARDINAL));
+               inWriter.write(',');
+               inWriter.write(inPoint.getLatitude().output(Coordinate.FORMAT_DEG_WITHOUT_CARDINAL));
+               inWriter.write(",0</coordinates>\n\t\t</Point>\n\t</Placemark>\n");
+       }
+
+
+       /**
+        * Export the specified trackpoint into the file
+        * @param inPoint trackpoint to export
+        * @param inWriter writer object
+        */
+       private void exportTrackpoint(DataPoint inPoint, Writer inWriter) throws IOException
+       {
+               inWriter.write(inPoint.getLongitude().output(Coordinate.FORMAT_DEG_WITHOUT_CARDINAL));
+               inWriter.write(',');
+               inWriter.write(inPoint.getLatitude().output(Coordinate.FORMAT_DEG_WITHOUT_CARDINAL));
+               // Altitude not exported, locked to ground by Google Earth
+               inWriter.write(",0\n");
+       }
+}
diff --git a/tim/prune/save/UpDownToggler.java b/tim/prune/save/UpDownToggler.java
new file mode 100644 (file)
index 0000000..274bfe9
--- /dev/null
@@ -0,0 +1,52 @@
+package tim.prune.save;
+
+import javax.swing.JButton;
+import javax.swing.ListSelectionModel;
+import javax.swing.event.ListSelectionEvent;
+import javax.swing.event.ListSelectionListener;
+
+
+/**
+ * Class to enable and disable a pair of up and down buttons
+ */
+public class UpDownToggler implements ListSelectionListener
+{
+       private JButton _upButton = null;
+       private JButton _downButton = null;
+       private int _maxIndex = 0;
+
+       /**
+        * Constructor giving buttons and size
+        * @param inUpButton up button
+        * @param inDownButton down button
+        * @param inListSize size of list
+        */
+       public UpDownToggler(JButton inUpButton, JButton inDownButton, int inListSize)
+       {
+               _upButton = inUpButton;
+               _downButton = inDownButton;
+               _maxIndex = inListSize - 1;
+       }
+
+
+       /**
+        * list selection has changed
+        */
+       public void valueChanged(ListSelectionEvent e)
+       {
+               ListSelectionModel lsm = (ListSelectionModel) e.getSource();
+               if (lsm.isSelectionEmpty())
+               {
+                       // no rows are selected
+                       _upButton.setEnabled(false);
+                       _downButton.setEnabled(false);
+               }
+               else
+               {
+                       // single row is selected
+                       int row = lsm.getMinSelectionIndex();
+                       _upButton.setEnabled(row > 0);
+                       _downButton.setEnabled(row >= 0 && row < _maxIndex);
+               }
+       }
+}
diff --git a/tim/prune/undo/UndoCompress.java b/tim/prune/undo/UndoCompress.java
new file mode 100644 (file)
index 0000000..843054d
--- /dev/null
@@ -0,0 +1,61 @@
+package tim.prune.undo;\r
+\r
+import tim.prune.I18nManager;\r
+import tim.prune.data.DataPoint;\r
+import tim.prune.data.Track;\r
+import tim.prune.data.TrackInfo;\r
+\r
+/**\r
+ * Operation to undo a track compression\r
+ */\r
+public class UndoCompress implements UndoOperation\r
+{\r
+       private DataPoint[] _contents = null;\r
+       protected int _numPointsDeleted = -1;\r
+\r
+\r
+       /**\r
+        * Constructor\r
+        * @param inTrack track contents to copy\r
+        */\r
+       public UndoCompress(Track inTrack)\r
+       {\r
+               _contents = inTrack.cloneContents();\r
+       }\r
+\r
+\r
+       /**\r
+        * Set the number of points deleted\r
+        * (only known after attempted compression)\r
+        * @param inNum number of points deleted\r
+        */\r
+       public void setNumPointsDeleted(int inNum)\r
+       {\r
+               _numPointsDeleted = inNum;\r
+       }\r
+\r
+\r
+       /**\r
+        * @return description of operation including parameters\r
+        */\r
+       public String getDescription()\r
+       {\r
+               String desc = I18nManager.getText("undo.compress");\r
+               if (_numPointsDeleted > 0)\r
+                       desc = desc + " (" + _numPointsDeleted + ")";\r
+               return desc;\r
+       }\r
+\r
+\r
+       /**\r
+        * Perform the undo operation on the given Track\r
+        * @param inTrackInfo TrackInfo object on which to perform the operation\r
+        */\r
+       public void performUndo(TrackInfo inTrackInfo) throws UndoException\r
+       {\r
+               // restore track to previous values\r
+               inTrackInfo.getTrack().replaceContents(_contents);\r
+               // clear selection\r
+               inTrackInfo.getSelection().clearAll();\r
+       }\r
+}
\ No newline at end of file
diff --git a/tim/prune/undo/UndoDeleteDuplicates.java b/tim/prune/undo/UndoDeleteDuplicates.java
new file mode 100644 (file)
index 0000000..ee11a8f
--- /dev/null
@@ -0,0 +1,32 @@
+package tim.prune.undo;
+
+import tim.prune.I18nManager;
+import tim.prune.data.Track;
+
+/**
+ * Undo duplicate deletion
+ */
+public class UndoDeleteDuplicates extends UndoCompress
+{
+
+       /**
+        * Constructor
+        * @param inTrack Track object
+        */
+       public UndoDeleteDuplicates(Track inTrack)
+       {
+               super(inTrack);
+       }
+
+
+       /**
+        * @return description of operation including parameters
+        */
+       public String getDescription()
+       {
+               String desc = I18nManager.getText("undo.deleteduplicates");
+               if (_numPointsDeleted > 0)
+                       desc = desc + " (" + _numPointsDeleted + ")";
+               return desc;
+       }
+}
diff --git a/tim/prune/undo/UndoDeletePoint.java b/tim/prune/undo/UndoDeletePoint.java
new file mode 100644 (file)
index 0000000..ebc4d89
--- /dev/null
@@ -0,0 +1,54 @@
+package tim.prune.undo;\r
+\r
+import tim.prune.I18nManager;\r
+import tim.prune.data.DataPoint;\r
+import tim.prune.data.Field;\r
+import tim.prune.data.TrackInfo;\r
+\r
+/**\r
+ * Operation to undo a delete of a single point\r
+ */\r
+public class UndoDeletePoint implements UndoOperation\r
+{\r
+       private int _pointIndex = -1;\r
+       private DataPoint _point = null;\r
+\r
+\r
+       /**\r
+        * Constructor\r
+        * @param inIndex index number of point within track\r
+        * @param inPoint data point\r
+        */\r
+       public UndoDeletePoint(int inIndex, DataPoint inPoint)\r
+       {\r
+               _pointIndex = inIndex;\r
+               _point = inPoint;\r
+       }\r
+\r
+\r
+       /**\r
+        * @return description of operation including point name if any\r
+        */\r
+       public String getDescription()\r
+       {\r
+               String desc = I18nManager.getText("undo.deletepoint");\r
+               String pointName = _point.getFieldValue(Field.WAYPT_NAME);\r
+               if (pointName != null && !pointName.equals(""))\r
+                       desc = desc + " " + pointName;\r
+               return desc;\r
+       }\r
+\r
+\r
+       /**\r
+        * Perform the undo operation on the given Track\r
+        * @param inTrack Track object on which to perform the operation\r
+        */\r
+       public void performUndo(TrackInfo inTrackInfo) throws UndoException\r
+       {\r
+               // restore point into track\r
+               if (!inTrackInfo.getTrack().insertPoint(_point, _pointIndex))\r
+               {\r
+                       throw new UndoException(getDescription());\r
+               }\r
+       }\r
+}
\ No newline at end of file
diff --git a/tim/prune/undo/UndoDeleteRange.java b/tim/prune/undo/UndoDeleteRange.java
new file mode 100644 (file)
index 0000000..6bb0b7d
--- /dev/null
@@ -0,0 +1,47 @@
+package tim.prune.undo;\r
+\r
+import tim.prune.I18nManager;\r
+import tim.prune.data.DataPoint;\r
+import tim.prune.data.TrackInfo;\r
+\r
+/**\r
+ * Operation to undo a delete of a range of points\r
+ */\r
+public class UndoDeleteRange implements UndoOperation\r
+{\r
+       private int _startIndex = -1;\r
+       private DataPoint[] _points = null;\r
+\r
+\r
+       /**\r
+        * Constructor\r
+        * @param inIndex index number of point within track\r
+        * @param inPoint data point\r
+        */\r
+       public UndoDeleteRange(TrackInfo inTrackInfo)\r
+       {\r
+               _startIndex = inTrackInfo.getSelection().getStart();\r
+               _points = inTrackInfo.cloneSelectedRange();\r
+       }\r
+\r
+\r
+       /**\r
+        * @return description of operation including range length\r
+        */\r
+       public String getDescription()\r
+       {\r
+               return I18nManager.getText("undo.deleterange")\r
+                       + " (" + _points.length + ")";\r
+       }\r
+\r
+\r
+       /**\r
+        * Perform the undo operation on the given Track\r
+        * @param inTrackInfo TrackInfo object on which to perform the operation\r
+        */\r
+       public void performUndo(TrackInfo inTrackInfo)\r
+       {\r
+               // restore point array into track\r
+               inTrackInfo.getTrack().insertRange(_points, _startIndex);\r
+       }\r
+}
\ No newline at end of file
diff --git a/tim/prune/undo/UndoException.java b/tim/prune/undo/UndoException.java
new file mode 100644 (file)
index 0000000..1c0cebb
--- /dev/null
@@ -0,0 +1,16 @@
+package tim.prune.undo;
+
+/**
+ * Exception thrown when undo operation fails
+ */
+public class UndoException extends Exception
+{
+       /**
+        * Constructor
+        * @param inMessage description of operation which failed
+        */
+       public UndoException(String inMessage)
+       {
+               super(inMessage);
+       }
+}
diff --git a/tim/prune/undo/UndoInsert.java b/tim/prune/undo/UndoInsert.java
new file mode 100644 (file)
index 0000000..51da595
--- /dev/null
@@ -0,0 +1,47 @@
+package tim.prune.undo;\r
+\r
+import tim.prune.I18nManager;\r
+import tim.prune.data.TrackInfo;\r
+\r
+/**\r
+ * Operation to undo an insertion (eg interpolate)\r
+ */\r
+public class UndoInsert implements UndoOperation\r
+{\r
+       private int _startPosition = 0;\r
+       private int _numInserted = 0;\r
+\r
+\r
+       /**\r
+        * Constructor\r
+        * @param inStart start of insert\r
+        * @param inNumInserted number of points inserted\r
+        */\r
+       public UndoInsert(int inStart, int inNumInserted)\r
+       {\r
+               _startPosition = inStart;\r
+               _numInserted = inNumInserted;\r
+       }\r
+\r
+\r
+       /**\r
+        * @return description of operation including parameters\r
+        */\r
+       public String getDescription()\r
+       {\r
+               return I18nManager.getText("undo.insert") + " (" + _numInserted + ")";\r
+       }\r
+\r
+\r
+       /**\r
+        * Perform the undo operation on the given TrackInfo\r
+        * @param inTrackInfo TrackInfo object on which to perform the operation\r
+        */\r
+       public void performUndo(TrackInfo inTrackInfo) throws UndoException\r
+       {\r
+               // restore track to previous values\r
+               inTrackInfo.getTrack().deleteRange(_startPosition, _startPosition + _numInserted - 1);\r
+               // reset selection\r
+               inTrackInfo.getSelection().select(_startPosition-1, _startPosition-1, _startPosition);\r
+       }\r
+}
\ No newline at end of file
diff --git a/tim/prune/undo/UndoLoad.java b/tim/prune/undo/UndoLoad.java
new file mode 100644 (file)
index 0000000..8bf2218
--- /dev/null
@@ -0,0 +1,88 @@
+package tim.prune.undo;\r
+\r
+import tim.prune.I18nManager;\r
+import tim.prune.data.DataPoint;\r
+import tim.prune.data.TrackInfo;\r
+\r
+/**\r
+ * Operation to undo a load operation\r
+ */\r
+public class UndoLoad implements UndoOperation\r
+{\r
+       private int _cropIndex = -1;\r
+       private int _numLoaded = -1;\r
+       private DataPoint[] _contents = null;\r
+       private String _previousFilename = null;\r
+\r
+\r
+       /**\r
+        * Constructor for appending\r
+        * @param inIndex index number of crop point\r
+        * @param inNumLoaded number of points loaded\r
+        */\r
+       public UndoLoad(int inIndex, int inNumLoaded)\r
+       {\r
+               _cropIndex = inIndex;\r
+               _numLoaded = inNumLoaded;\r
+               _contents = null;\r
+               _previousFilename = null;\r
+       }\r
+\r
+\r
+       /**\r
+        * Constructor for replacing\r
+        * @param inOldTrack track being replaced\r
+        * @param inNumLoaded number of points loaded\r
+        */\r
+       public UndoLoad(TrackInfo inOldTrackInfo, int inNumLoaded)\r
+       {\r
+               _cropIndex = -1;\r
+               _numLoaded = inNumLoaded;\r
+               _contents = inOldTrackInfo.getTrack().cloneContents();\r
+               if (inOldTrackInfo.getFileInfo().getNumFiles() == 1)\r
+                       _previousFilename = inOldTrackInfo.getFileInfo().getFilename();\r
+       }\r
+\r
+\r
+       /**\r
+        * @return description of operation including number of points loaded\r
+        */\r
+       public String getDescription()\r
+       {\r
+               String desc = I18nManager.getText("undo.load");\r
+               if (_numLoaded > 0)\r
+                       desc = desc + " (" + _numLoaded + ")";\r
+               return desc;\r
+       }\r
+\r
+\r
+       /**\r
+        * Perform the undo operation on the given Track\r
+        * @param inTrackInfo TrackInfo object on which to perform the operation\r
+        */\r
+       public void performUndo(TrackInfo inTrackInfo) throws UndoException\r
+       {\r
+               // remove file from fileinfo\r
+               inTrackInfo.getFileInfo().removeFile();\r
+               if (_previousFilename != null)\r
+               {\r
+                       inTrackInfo.getFileInfo().setFile(_previousFilename);\r
+               }\r
+               // Crop / replace\r
+               if (_contents == null)\r
+               {\r
+                       // crop track to previous size\r
+                       inTrackInfo.getTrack().cropTo(_cropIndex);\r
+               }\r
+               else\r
+               {\r
+                       // replace track contents with old\r
+                       if (!inTrackInfo.getTrack().replaceContents(_contents))\r
+                       {\r
+                               throw new UndoException(getDescription());\r
+                       }\r
+               }\r
+               // clear selection\r
+               inTrackInfo.getSelection().clearAll();\r
+       }\r
+}
\ No newline at end of file
diff --git a/tim/prune/undo/UndoOperation.java b/tim/prune/undo/UndoOperation.java
new file mode 100644 (file)
index 0000000..5efa1fe
--- /dev/null
@@ -0,0 +1,21 @@
+package tim.prune.undo;\r
+\r
+import tim.prune.data.TrackInfo;\r
+\r
+/**\r
+ * Interface implemented by all Undo Operations\r
+ */\r
+public interface UndoOperation\r
+{\r
+       /**\r
+        * Get the description of this operation\r
+        * @return description of operation including parameters\r
+        */\r
+       public String getDescription();\r
+\r
+       /**\r
+        * Perform the undo operation on the specified track\r
+        * @param inTrackInfo TrackInfo object on which to perform the operation\r
+        */\r
+       public void performUndo(TrackInfo inTrackInfo) throws UndoException;\r
+}
\ No newline at end of file
diff --git a/tim/prune/undo/UndoRearrangeWaypoints.java b/tim/prune/undo/UndoRearrangeWaypoints.java
new file mode 100644 (file)
index 0000000..d48dc2a
--- /dev/null
@@ -0,0 +1,44 @@
+package tim.prune.undo;\r
+\r
+import tim.prune.I18nManager;\r
+import tim.prune.data.DataPoint;\r
+import tim.prune.data.Track;\r
+import tim.prune.data.TrackInfo;\r
+\r
+/**\r
+ * Operation to undo a waypoint rearrangement\r
+ */\r
+public class UndoRearrangeWaypoints implements UndoOperation\r
+{\r
+       private DataPoint[] _contents = null;\r
+\r
+\r
+       /**\r
+        * Constructor\r
+        * @param inTrack track contents to copy\r
+        */\r
+       public UndoRearrangeWaypoints(Track inTrack)\r
+       {\r
+               _contents = inTrack.cloneContents();\r
+       }\r
+\r
+\r
+       /**\r
+        * @return description of operation\r
+        */\r
+       public String getDescription()\r
+       {\r
+               return I18nManager.getText("undo.rearrangewaypoints");\r
+       }\r
+\r
+\r
+       /**\r
+        * Perform the undo operation on the given Track\r
+        * @param inTrackInfo TrackInfo object on which to perform the operation\r
+        */\r
+       public void performUndo(TrackInfo inTrackInfo) throws UndoException\r
+       {\r
+               // restore track to previous values\r
+               inTrackInfo.getTrack().replaceContents(_contents);\r
+       }\r
+}
\ No newline at end of file
diff --git a/tim/prune/undo/UndoReverseSection.java b/tim/prune/undo/UndoReverseSection.java
new file mode 100644 (file)
index 0000000..73fe692
--- /dev/null
@@ -0,0 +1,46 @@
+package tim.prune.undo;
+
+import tim.prune.I18nManager;
+import tim.prune.data.TrackInfo;
+
+/**
+ * Undo reversal of track section
+ */
+public class UndoReverseSection implements UndoOperation
+{
+       private int _startIndex, _endIndex;
+
+
+       /**
+        * Constructor
+        * @param inStart start index of section
+        * @param inEnd end index of section
+        */
+       public UndoReverseSection(int inStart, int inEnd)
+       {
+               _startIndex = inStart;
+               _endIndex = inEnd;
+       }
+
+
+       /**
+        * @return description of operation
+        */
+       public String getDescription()
+       {
+               return I18nManager.getText("undo.reverse");
+       }
+
+
+       /**
+        * Perform the undo operation on the given Track
+        * @param inTrackInfo TrackInfo object on which to perform the operation
+        */
+       public void performUndo(TrackInfo inTrackInfo) throws UndoException
+       {
+               if (!inTrackInfo.getTrack().reverseRange(_startIndex, _endIndex))
+               {
+                       throw new UndoException(getDescription());
+               }
+       }
+}