001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer.geoimage; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005import static org.openstreetmap.josm.tools.I18n.trn; 006 007import java.awt.BorderLayout; 008import java.awt.Cursor; 009import java.awt.Dimension; 010import java.awt.FlowLayout; 011import java.awt.GridBagConstraints; 012import java.awt.GridBagLayout; 013import java.awt.event.ActionEvent; 014import java.awt.event.ActionListener; 015import java.awt.event.FocusEvent; 016import java.awt.event.FocusListener; 017import java.awt.event.ItemEvent; 018import java.awt.event.ItemListener; 019import java.awt.event.WindowAdapter; 020import java.awt.event.WindowEvent; 021import java.io.File; 022import java.io.FileInputStream; 023import java.io.IOException; 024import java.io.InputStream; 025import java.text.DateFormat; 026import java.text.ParseException; 027import java.text.SimpleDateFormat; 028import java.util.ArrayList; 029import java.util.Collection; 030import java.util.Collections; 031import java.util.Comparator; 032import java.util.Date; 033import java.util.Dictionary; 034import java.util.Hashtable; 035import java.util.List; 036import java.util.TimeZone; 037import java.util.zip.GZIPInputStream; 038 039import javax.swing.AbstractAction; 040import javax.swing.AbstractListModel; 041import javax.swing.BorderFactory; 042import javax.swing.JButton; 043import javax.swing.JCheckBox; 044import javax.swing.JFileChooser; 045import javax.swing.JLabel; 046import javax.swing.JList; 047import javax.swing.JOptionPane; 048import javax.swing.JPanel; 049import javax.swing.JScrollPane; 050import javax.swing.JSeparator; 051import javax.swing.JSlider; 052import javax.swing.ListSelectionModel; 053import javax.swing.SwingConstants; 054import javax.swing.event.ChangeEvent; 055import javax.swing.event.ChangeListener; 056import javax.swing.event.DocumentEvent; 057import javax.swing.event.DocumentListener; 058import javax.swing.event.ListSelectionEvent; 059import javax.swing.event.ListSelectionListener; 060import javax.swing.filechooser.FileFilter; 061 062import org.openstreetmap.josm.Main; 063import org.openstreetmap.josm.actions.DiskAccessAction; 064import org.openstreetmap.josm.data.gpx.GpxConstants; 065import org.openstreetmap.josm.data.gpx.GpxData; 066import org.openstreetmap.josm.data.gpx.GpxTrack; 067import org.openstreetmap.josm.data.gpx.GpxTrackSegment; 068import org.openstreetmap.josm.data.gpx.WayPoint; 069import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 070import org.openstreetmap.josm.gui.ExtendedDialog; 071import org.openstreetmap.josm.gui.layer.GpxLayer; 072import org.openstreetmap.josm.gui.layer.Layer; 073import org.openstreetmap.josm.gui.widgets.AbstractFileChooser; 074import org.openstreetmap.josm.gui.widgets.JosmComboBox; 075import org.openstreetmap.josm.gui.widgets.JosmTextField; 076import org.openstreetmap.josm.io.GpxReader; 077import org.openstreetmap.josm.io.JpgImporter; 078import org.openstreetmap.josm.tools.ExifReader; 079import org.openstreetmap.josm.tools.GBC; 080import org.openstreetmap.josm.tools.ImageProvider; 081import org.openstreetmap.josm.tools.Utils; 082import org.openstreetmap.josm.tools.date.DateUtils; 083import org.openstreetmap.josm.tools.date.PrimaryDateParser; 084import org.xml.sax.SAXException; 085 086/** 087 * This class displays the window to select the GPX file and the offset (timezone + delta). 088 * Then it correlates the images of the layer with that GPX file. 089 */ 090public class CorrelateGpxWithImages extends AbstractAction { 091 092 private static List<GpxData> loadedGpxData = new ArrayList<>(); 093 094 private final transient GeoImageLayer yLayer; 095 private double timezone; 096 private long delta; 097 098 /** 099 * Constructs a new {@code CorrelateGpxWithImages} action. 100 * @param layer The image layer 101 */ 102 public CorrelateGpxWithImages(GeoImageLayer layer) { 103 super(tr("Correlate to GPX"), ImageProvider.get("dialogs/geoimage/gpx2img")); 104 this.yLayer = layer; 105 } 106 107 private final class SyncDialogWindowListener extends WindowAdapter { 108 private static final int CANCEL = -1; 109 private static final int DONE = 0; 110 private static final int AGAIN = 1; 111 private static final int NOTHING = 2; 112 113 private int checkAndSave() { 114 if (syncDialog.isVisible()) 115 // nothing happened: JOSM was minimized or similar 116 return NOTHING; 117 int answer = syncDialog.getValue(); 118 if (answer != 1) 119 return CANCEL; 120 121 // Parse values again, to display an error if the format is not recognized 122 try { 123 timezone = parseTimezone(tfTimezone.getText().trim()); 124 } catch (ParseException e) { 125 JOptionPane.showMessageDialog(Main.parent, e.getMessage(), 126 tr("Invalid timezone"), JOptionPane.ERROR_MESSAGE); 127 return AGAIN; 128 } 129 130 try { 131 delta = parseOffset(tfOffset.getText().trim()); 132 } catch (ParseException e) { 133 JOptionPane.showMessageDialog(Main.parent, e.getMessage(), 134 tr("Invalid offset"), JOptionPane.ERROR_MESSAGE); 135 return AGAIN; 136 } 137 138 if (lastNumMatched == 0 && new ExtendedDialog( 139 Main.parent, 140 tr("Correlate images with GPX track"), 141 new String[] {tr("OK"), tr("Try Again")}). 142 setContent(tr("No images could be matched!")). 143 setButtonIcons(new String[] {"ok", "dialogs/refresh"}). 144 showDialog().getValue() == 2) 145 return AGAIN; 146 return DONE; 147 } 148 149 @Override 150 public void windowDeactivated(WindowEvent e) { 151 int result = checkAndSave(); 152 switch (result) { 153 case NOTHING: 154 break; 155 case CANCEL: 156 if (yLayer != null) { 157 if (yLayer.data != null) { 158 for (ImageEntry ie : yLayer.data) { 159 ie.discardTmp(); 160 } 161 } 162 yLayer.updateBufferAndRepaint(); 163 } 164 break; 165 case AGAIN: 166 actionPerformed(null); 167 break; 168 case DONE: 169 Main.pref.put("geoimage.timezone", formatTimezone(timezone)); 170 Main.pref.put("geoimage.delta", Long.toString(delta * 1000)); 171 Main.pref.put("geoimage.showThumbs", yLayer.useThumbs); 172 173 yLayer.useThumbs = cbShowThumbs.isSelected(); 174 yLayer.startLoadThumbs(); 175 176 // Search whether an other layer has yet defined some bounding box. 177 // If none, we'll zoom to the bounding box of the layer with the photos. 178 boolean boundingBoxedLayerFound = false; 179 for (Layer l: Main.map.mapView.getAllLayers()) { 180 if (l != yLayer) { 181 BoundingXYVisitor bbox = new BoundingXYVisitor(); 182 l.visitBoundingBox(bbox); 183 if (bbox.getBounds() != null) { 184 boundingBoxedLayerFound = true; 185 break; 186 } 187 } 188 } 189 if (!boundingBoxedLayerFound) { 190 BoundingXYVisitor bbox = new BoundingXYVisitor(); 191 yLayer.visitBoundingBox(bbox); 192 Main.map.mapView.zoomTo(bbox); 193 } 194 195 if (yLayer.data != null) { 196 for (ImageEntry ie : yLayer.data) { 197 ie.applyTmp(); 198 } 199 } 200 201 yLayer.updateBufferAndRepaint(); 202 203 break; 204 default: 205 throw new IllegalStateException(); 206 } 207 } 208 } 209 210 private static class GpxDataWrapper { 211 private final String name; 212 private final GpxData data; 213 private final File file; 214 215 GpxDataWrapper(String name, GpxData data, File file) { 216 this.name = name; 217 this.data = data; 218 this.file = file; 219 } 220 221 @Override 222 public String toString() { 223 return name; 224 } 225 } 226 227 private ExtendedDialog syncDialog; 228 private final transient List<GpxDataWrapper> gpxLst = new ArrayList<>(); 229 private JPanel outerPanel; 230 private JosmComboBox<GpxDataWrapper> cbGpx; 231 private JosmTextField tfTimezone; 232 private JosmTextField tfOffset; 233 private JCheckBox cbExifImg; 234 private JCheckBox cbTaggedImg; 235 private JCheckBox cbShowThumbs; 236 private JLabel statusBarText; 237 238 // remember the last number of matched photos 239 private int lastNumMatched; 240 241 /** This class is called when the user doesn't find the GPX file he needs in the files that have 242 * been loaded yet. It displays a FileChooser dialog to select the GPX file to be loaded. 243 */ 244 private class LoadGpxDataActionListener implements ActionListener { 245 246 @Override 247 public void actionPerformed(ActionEvent arg0) { 248 FileFilter filter = new FileFilter() { 249 @Override 250 public boolean accept(File f) { 251 return f.isDirectory() || Utils.hasExtension(f, "gpx", "gpx.gz"); 252 } 253 254 @Override 255 public String getDescription() { 256 return tr("GPX Files (*.gpx *.gpx.gz)"); 257 } 258 }; 259 AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(true, false, null, filter, JFileChooser.FILES_ONLY, null); 260 if (fc == null) 261 return; 262 File sel = fc.getSelectedFile(); 263 264 try { 265 outerPanel.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); 266 267 for (int i = gpxLst.size() - 1; i >= 0; i--) { 268 GpxDataWrapper wrapper = gpxLst.get(i); 269 if (wrapper.file != null && sel.equals(wrapper.file)) { 270 cbGpx.setSelectedIndex(i); 271 if (!sel.getName().equals(wrapper.name)) { 272 JOptionPane.showMessageDialog( 273 Main.parent, 274 tr("File {0} is loaded yet under the name \"{1}\"", sel.getName(), wrapper.name), 275 tr("Error"), 276 JOptionPane.ERROR_MESSAGE 277 ); 278 } 279 return; 280 } 281 } 282 GpxData data = null; 283 try (InputStream iStream = createInputStream(sel)) { 284 GpxReader reader = new GpxReader(iStream); 285 reader.parse(false); 286 data = reader.getGpxData(); 287 data.storageFile = sel; 288 289 } catch (SAXException x) { 290 Main.error(x); 291 JOptionPane.showMessageDialog( 292 Main.parent, 293 tr("Error while parsing {0}", sel.getName())+": "+x.getMessage(), 294 tr("Error"), 295 JOptionPane.ERROR_MESSAGE 296 ); 297 return; 298 } catch (IOException x) { 299 Main.error(x); 300 JOptionPane.showMessageDialog( 301 Main.parent, 302 tr("Could not read \"{0}\"", sel.getName())+'\n'+x.getMessage(), 303 tr("Error"), 304 JOptionPane.ERROR_MESSAGE 305 ); 306 return; 307 } 308 309 loadedGpxData.add(data); 310 if (gpxLst.get(0).file == null) { 311 gpxLst.remove(0); 312 } 313 gpxLst.add(new GpxDataWrapper(sel.getName(), data, sel)); 314 cbGpx.setSelectedIndex(cbGpx.getItemCount() - 1); 315 } finally { 316 outerPanel.setCursor(Cursor.getDefaultCursor()); 317 } 318 } 319 320 private InputStream createInputStream(File sel) throws IOException { 321 if (Utils.hasExtension(sel, "gpx.gz")) { 322 return new GZIPInputStream(new FileInputStream(sel)); 323 } else { 324 return new FileInputStream(sel); 325 } 326 } 327 } 328 329 /** 330 * This action listener is called when the user has a photo of the time of his GPS receiver. It 331 * displays the list of photos of the layer, and upon selection displays the selected photo. 332 * From that photo, the user can key in the time of the GPS. 333 * Then values of timezone and delta are set. 334 * @author chris 335 * 336 */ 337 private class SetOffsetActionListener implements ActionListener { 338 private JPanel panel; 339 private JLabel lbExifTime; 340 private JosmTextField tfGpsTime; 341 private JosmComboBox<String> cbTimezones; 342 private ImageDisplay imgDisp; 343 private JList<String> imgList; 344 345 @Override 346 public void actionPerformed(ActionEvent arg0) { 347 SimpleDateFormat dateFormat = (SimpleDateFormat) DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM); 348 349 panel = new JPanel(); 350 panel.setLayout(new BorderLayout()); 351 panel.add(new JLabel(tr("<html>Take a photo of your GPS receiver while it displays the time.<br>" 352 + "Display that photo here.<br>" 353 + "And then, simply capture the time you read on the photo and select a timezone<hr></html>")), 354 BorderLayout.NORTH); 355 356 imgDisp = new ImageDisplay(); 357 imgDisp.setPreferredSize(new Dimension(300, 225)); 358 panel.add(imgDisp, BorderLayout.CENTER); 359 360 JPanel panelTf = new JPanel(); 361 panelTf.setLayout(new GridBagLayout()); 362 363 GridBagConstraints gc = new GridBagConstraints(); 364 gc.gridx = gc.gridy = 0; 365 gc.gridwidth = gc.gridheight = 1; 366 gc.weightx = gc.weighty = 0.0; 367 gc.fill = GridBagConstraints.NONE; 368 gc.anchor = GridBagConstraints.WEST; 369 panelTf.add(new JLabel(tr("Photo time (from exif):")), gc); 370 371 lbExifTime = new JLabel(); 372 gc.gridx = 1; 373 gc.weightx = 1.0; 374 gc.fill = GridBagConstraints.HORIZONTAL; 375 gc.gridwidth = 2; 376 panelTf.add(lbExifTime, gc); 377 378 gc.gridx = 0; 379 gc.gridy = 1; 380 gc.gridwidth = gc.gridheight = 1; 381 gc.weightx = gc.weighty = 0.0; 382 gc.fill = GridBagConstraints.NONE; 383 gc.anchor = GridBagConstraints.WEST; 384 panelTf.add(new JLabel(tr("Gps time (read from the above photo): ")), gc); 385 386 tfGpsTime = new JosmTextField(12); 387 tfGpsTime.setEnabled(false); 388 tfGpsTime.setMinimumSize(new Dimension(155, tfGpsTime.getMinimumSize().height)); 389 gc.gridx = 1; 390 gc.weightx = 1.0; 391 gc.fill = GridBagConstraints.HORIZONTAL; 392 panelTf.add(tfGpsTime, gc); 393 394 gc.gridx = 2; 395 gc.weightx = 0.2; 396 panelTf.add(new JLabel(" ["+dateFormat.toLocalizedPattern()+']'), gc); 397 398 gc.gridx = 0; 399 gc.gridy = 2; 400 gc.gridwidth = gc.gridheight = 1; 401 gc.weightx = gc.weighty = 0.0; 402 gc.fill = GridBagConstraints.NONE; 403 gc.anchor = GridBagConstraints.WEST; 404 panelTf.add(new JLabel(tr("I am in the timezone of: ")), gc); 405 406 String[] tmp = TimeZone.getAvailableIDs(); 407 List<String> vtTimezones = new ArrayList<>(tmp.length); 408 409 for (String tzStr : tmp) { 410 TimeZone tz = TimeZone.getTimeZone(tzStr); 411 412 String tzDesc = new StringBuilder(tzStr).append(" (") 413 .append(formatTimezone(tz.getRawOffset() / 3600000.0)) 414 .append(')').toString(); 415 vtTimezones.add(tzDesc); 416 } 417 418 Collections.sort(vtTimezones); 419 420 cbTimezones = new JosmComboBox<>(vtTimezones.toArray(new String[0])); 421 422 String tzId = Main.pref.get("geoimage.timezoneid", ""); 423 TimeZone defaultTz; 424 if (tzId.isEmpty()) { 425 defaultTz = TimeZone.getDefault(); 426 } else { 427 defaultTz = TimeZone.getTimeZone(tzId); 428 } 429 430 cbTimezones.setSelectedItem(new StringBuilder(defaultTz.getID()).append(" (") 431 .append(formatTimezone(defaultTz.getRawOffset() / 3600000.0)) 432 .append(')').toString()); 433 434 gc.gridx = 1; 435 gc.weightx = 1.0; 436 gc.gridwidth = 2; 437 gc.fill = GridBagConstraints.HORIZONTAL; 438 panelTf.add(cbTimezones, gc); 439 440 panel.add(panelTf, BorderLayout.SOUTH); 441 442 JPanel panelLst = new JPanel(); 443 panelLst.setLayout(new BorderLayout()); 444 445 imgList = new JList<>(new AbstractListModel<String>() { 446 @Override 447 public String getElementAt(int i) { 448 return yLayer.data.get(i).getFile().getName(); 449 } 450 451 @Override 452 public int getSize() { 453 return yLayer.data != null ? yLayer.data.size() : 0; 454 } 455 }); 456 imgList.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION); 457 imgList.getSelectionModel().addListSelectionListener(new ListSelectionListener() { 458 459 @Override 460 public void valueChanged(ListSelectionEvent arg0) { 461 int index = imgList.getSelectedIndex(); 462 Integer orientation = null; 463 try { 464 orientation = ExifReader.readOrientation(yLayer.data.get(index).getFile()); 465 } catch (Exception e) { 466 Main.warn(e); 467 } 468 imgDisp.setImage(yLayer.data.get(index).getFile(), orientation); 469 Date date = yLayer.data.get(index).getExifTime(); 470 if (date != null) { 471 DateFormat df = DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM); 472 lbExifTime.setText(df.format(date)); 473 tfGpsTime.setText(df.format(date)); 474 tfGpsTime.setCaretPosition(tfGpsTime.getText().length()); 475 tfGpsTime.setEnabled(true); 476 tfGpsTime.requestFocus(); 477 } else { 478 lbExifTime.setText(tr("No date")); 479 tfGpsTime.setText(""); 480 tfGpsTime.setEnabled(false); 481 } 482 } 483 }); 484 panelLst.add(new JScrollPane(imgList), BorderLayout.CENTER); 485 486 JButton openButton = new JButton(tr("Open another photo")); 487 openButton.addActionListener(new ActionListener() { 488 489 @Override 490 public void actionPerformed(ActionEvent ae) { 491 AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(true, false, null, 492 JpgImporter.FILE_FILTER_WITH_FOLDERS, JFileChooser.FILES_ONLY, "geoimage.lastdirectory"); 493 if (fc == null) 494 return; 495 File sel = fc.getSelectedFile(); 496 497 Integer orientation = null; 498 try { 499 orientation = ExifReader.readOrientation(sel); 500 } catch (Exception e) { 501 Main.warn(e); 502 } 503 imgDisp.setImage(sel, orientation); 504 505 Date date = null; 506 try { 507 date = ExifReader.readTime(sel); 508 } catch (Exception e) { 509 Main.warn(e); 510 } 511 if (date != null) { 512 lbExifTime.setText(DateUtils.getDateTimeFormat(DateFormat.SHORT, DateFormat.MEDIUM).format(date)); 513 tfGpsTime.setText(DateUtils.getDateFormat(DateFormat.SHORT).format(date)+' '); 514 tfGpsTime.setEnabled(true); 515 } else { 516 lbExifTime.setText(tr("No date")); 517 tfGpsTime.setText(""); 518 tfGpsTime.setEnabled(false); 519 } 520 } 521 }); 522 panelLst.add(openButton, BorderLayout.PAGE_END); 523 524 panel.add(panelLst, BorderLayout.LINE_START); 525 526 boolean isOk = false; 527 while (!isOk) { 528 int answer = JOptionPane.showConfirmDialog( 529 Main.parent, panel, 530 tr("Synchronize time from a photo of the GPS receiver"), 531 JOptionPane.OK_CANCEL_OPTION, 532 JOptionPane.QUESTION_MESSAGE 533 ); 534 if (answer == JOptionPane.CANCEL_OPTION) 535 return; 536 537 long delta; 538 539 try { 540 delta = dateFormat.parse(lbExifTime.getText()).getTime() 541 - dateFormat.parse(tfGpsTime.getText()).getTime(); 542 } catch (ParseException e) { 543 JOptionPane.showMessageDialog(Main.parent, tr("Error while parsing the date.\n" 544 + "Please use the requested format"), 545 tr("Invalid date"), JOptionPane.ERROR_MESSAGE); 546 continue; 547 } 548 549 String selectedTz = (String) cbTimezones.getSelectedItem(); 550 int pos = selectedTz.lastIndexOf('('); 551 tzId = selectedTz.substring(0, pos - 1); 552 String tzValue = selectedTz.substring(pos + 1, selectedTz.length() - 1); 553 554 Main.pref.put("geoimage.timezoneid", tzId); 555 tfOffset.setText(Long.toString(delta / 1000)); 556 tfTimezone.setText(tzValue); 557 558 isOk = true; 559 560 } 561 statusBarUpdater.updateStatusBar(); 562 yLayer.updateBufferAndRepaint(); 563 } 564 } 565 566 @Override 567 public void actionPerformed(ActionEvent arg0) { 568 // Construct the list of loaded GPX tracks 569 Collection<Layer> layerLst = Main.map.mapView.getAllLayers(); 570 GpxDataWrapper defaultItem = null; 571 for (Layer cur : layerLst) { 572 if (cur instanceof GpxLayer) { 573 GpxLayer curGpx = (GpxLayer) cur; 574 GpxDataWrapper gdw = new GpxDataWrapper(curGpx.getName(), curGpx.data, curGpx.data.storageFile); 575 gpxLst.add(gdw); 576 if (cur == yLayer.gpxLayer) { 577 defaultItem = gdw; 578 } 579 } 580 } 581 for (GpxData data : loadedGpxData) { 582 gpxLst.add(new GpxDataWrapper(data.storageFile.getName(), 583 data, 584 data.storageFile)); 585 } 586 587 if (gpxLst.isEmpty()) { 588 gpxLst.add(new GpxDataWrapper(tr("<No GPX track loaded yet>"), null, null)); 589 } 590 591 JPanel panelCb = new JPanel(); 592 593 panelCb.add(new JLabel(tr("GPX track: "))); 594 595 cbGpx = new JosmComboBox<>(gpxLst.toArray(new GpxDataWrapper[0])); 596 if (defaultItem != null) { 597 cbGpx.setSelectedItem(defaultItem); 598 } 599 cbGpx.addActionListener(statusBarUpdaterWithRepaint); 600 panelCb.add(cbGpx); 601 602 JButton buttonOpen = new JButton(tr("Open another GPX trace")); 603 buttonOpen.addActionListener(new LoadGpxDataActionListener()); 604 panelCb.add(buttonOpen); 605 606 JPanel panelTf = new JPanel(); 607 panelTf.setLayout(new GridBagLayout()); 608 609 String prefTimezone = Main.pref.get("geoimage.timezone", "0:00"); 610 if (prefTimezone == null) { 611 prefTimezone = "0:00"; 612 } 613 try { 614 timezone = parseTimezone(prefTimezone); 615 } catch (ParseException e) { 616 timezone = 0; 617 } 618 619 tfTimezone = new JosmTextField(10); 620 tfTimezone.setText(formatTimezone(timezone)); 621 622 try { 623 delta = parseOffset(Main.pref.get("geoimage.delta", "0")); 624 } catch (ParseException e) { 625 delta = 0; 626 } 627 delta = delta / 1000; // milliseconds -> seconds 628 629 tfOffset = new JosmTextField(10); 630 tfOffset.setText(Long.toString(delta)); 631 632 JButton buttonViewGpsPhoto = new JButton(tr("<html>Use photo of an accurate clock,<br>" 633 + "e.g. GPS receiver display</html>")); 634 buttonViewGpsPhoto.setIcon(ImageProvider.get("clock")); 635 buttonViewGpsPhoto.addActionListener(new SetOffsetActionListener()); 636 637 JButton buttonAutoGuess = new JButton(tr("Auto-Guess")); 638 buttonAutoGuess.setToolTipText(tr("Matches first photo with first gpx point")); 639 buttonAutoGuess.addActionListener(new AutoGuessActionListener()); 640 641 JButton buttonAdjust = new JButton(tr("Manual adjust")); 642 buttonAdjust.addActionListener(new AdjustActionListener()); 643 644 JLabel labelPosition = new JLabel(tr("Override position for: ")); 645 646 int numAll = getSortedImgList(true, true).size(); 647 int numExif = numAll - getSortedImgList(false, true).size(); 648 int numTagged = numAll - getSortedImgList(true, false).size(); 649 650 cbExifImg = new JCheckBox(tr("Images with geo location in exif data ({0}/{1})", numExif, numAll)); 651 cbExifImg.setEnabled(numExif != 0); 652 653 cbTaggedImg = new JCheckBox(tr("Images that are already tagged ({0}/{1})", numTagged, numAll), true); 654 cbTaggedImg.setEnabled(numTagged != 0); 655 656 labelPosition.setEnabled(cbExifImg.isEnabled() || cbTaggedImg.isEnabled()); 657 658 boolean ticked = yLayer.thumbsLoaded || Main.pref.getBoolean("geoimage.showThumbs", false); 659 cbShowThumbs = new JCheckBox(tr("Show Thumbnail images on the map"), ticked); 660 cbShowThumbs.setEnabled(!yLayer.thumbsLoaded); 661 662 int y = 0; 663 GBC gbc = GBC.eol(); 664 gbc.gridx = 0; 665 gbc.gridy = y++; 666 panelTf.add(panelCb, gbc); 667 668 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 12); 669 gbc.gridx = 0; 670 gbc.gridy = y++; 671 panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc); 672 673 gbc = GBC.std(); 674 gbc.gridx = 0; 675 gbc.gridy = y; 676 panelTf.add(new JLabel(tr("Timezone: ")), gbc); 677 678 gbc = GBC.std().fill(GBC.HORIZONTAL); 679 gbc.gridx = 1; 680 gbc.gridy = y++; 681 gbc.weightx = 1.; 682 panelTf.add(tfTimezone, gbc); 683 684 gbc = GBC.std(); 685 gbc.gridx = 0; 686 gbc.gridy = y; 687 panelTf.add(new JLabel(tr("Offset:")), gbc); 688 689 gbc = GBC.std().fill(GBC.HORIZONTAL); 690 gbc.gridx = 1; 691 gbc.gridy = y++; 692 gbc.weightx = 1.; 693 panelTf.add(tfOffset, gbc); 694 695 gbc = GBC.std().insets(5, 5, 5, 5); 696 gbc.gridx = 2; 697 gbc.gridy = y-2; 698 gbc.gridheight = 2; 699 gbc.gridwidth = 2; 700 gbc.fill = GridBagConstraints.BOTH; 701 gbc.weightx = 0.5; 702 panelTf.add(buttonViewGpsPhoto, gbc); 703 704 gbc = GBC.std().fill(GBC.BOTH).insets(5, 5, 5, 5); 705 gbc.gridx = 2; 706 gbc.gridy = y++; 707 gbc.weightx = 0.5; 708 panelTf.add(buttonAutoGuess, gbc); 709 710 gbc.gridx = 3; 711 panelTf.add(buttonAdjust, gbc); 712 713 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 12, 0, 0); 714 gbc.gridx = 0; 715 gbc.gridy = y++; 716 panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc); 717 718 gbc = GBC.eol(); 719 gbc.gridx = 0; 720 gbc.gridy = y++; 721 panelTf.add(labelPosition, gbc); 722 723 gbc = GBC.eol(); 724 gbc.gridx = 1; 725 gbc.gridy = y++; 726 panelTf.add(cbExifImg, gbc); 727 728 gbc = GBC.eol(); 729 gbc.gridx = 1; 730 gbc.gridy = y++; 731 panelTf.add(cbTaggedImg, gbc); 732 733 gbc = GBC.eol(); 734 gbc.gridx = 0; 735 gbc.gridy = y++; 736 panelTf.add(cbShowThumbs, gbc); 737 738 final JPanel statusBar = new JPanel(); 739 statusBar.setLayout(new FlowLayout(FlowLayout.LEFT, 0, 0)); 740 statusBar.setBorder(BorderFactory.createLoweredBevelBorder()); 741 statusBarText = new JLabel(" "); 742 statusBarText.setFont(statusBarText.getFont().deriveFont(8)); 743 statusBar.add(statusBarText); 744 745 tfTimezone.addFocusListener(repaintTheMap); 746 tfOffset.addFocusListener(repaintTheMap); 747 748 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 749 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 750 cbExifImg.addItemListener(statusBarUpdaterWithRepaint); 751 cbTaggedImg.addItemListener(statusBarUpdaterWithRepaint); 752 753 statusBarUpdater.updateStatusBar(); 754 755 outerPanel = new JPanel(); 756 outerPanel.setLayout(new BorderLayout()); 757 outerPanel.add(statusBar, BorderLayout.PAGE_END); 758 759 syncDialog = new ExtendedDialog( 760 Main.parent, 761 tr("Correlate images with GPX track"), 762 new String[] {tr("Correlate"), tr("Cancel")}, 763 false 764 ); 765 syncDialog.setContent(panelTf, false); 766 syncDialog.setButtonIcons(new String[] {"ok", "cancel"}); 767 syncDialog.setupDialog(); 768 outerPanel.add(syncDialog.getContentPane(), BorderLayout.PAGE_START); 769 syncDialog.setContentPane(outerPanel); 770 syncDialog.pack(); 771 syncDialog.addWindowListener(new SyncDialogWindowListener()); 772 syncDialog.showDialog(); 773 } 774 775 private final transient StatusBarUpdater statusBarUpdater = new StatusBarUpdater(false); 776 private final transient StatusBarUpdater statusBarUpdaterWithRepaint = new StatusBarUpdater(true); 777 778 private class StatusBarUpdater implements DocumentListener, ItemListener, ActionListener { 779 private final boolean doRepaint; 780 781 StatusBarUpdater(boolean doRepaint) { 782 this.doRepaint = doRepaint; 783 } 784 785 @Override 786 public void insertUpdate(DocumentEvent ev) { 787 updateStatusBar(); 788 } 789 790 @Override 791 public void removeUpdate(DocumentEvent ev) { 792 updateStatusBar(); 793 } 794 795 @Override 796 public void changedUpdate(DocumentEvent ev) { 797 } 798 799 @Override 800 public void itemStateChanged(ItemEvent e) { 801 updateStatusBar(); 802 } 803 804 @Override 805 public void actionPerformed(ActionEvent e) { 806 updateStatusBar(); 807 } 808 809 public void updateStatusBar() { 810 statusBarText.setText(statusText()); 811 if (doRepaint) { 812 yLayer.updateBufferAndRepaint(); 813 } 814 } 815 816 private String statusText() { 817 try { 818 timezone = parseTimezone(tfTimezone.getText().trim()); 819 delta = parseOffset(tfOffset.getText().trim()); 820 } catch (ParseException e) { 821 return e.getMessage(); 822 } 823 824 // The selection of images we are about to correlate may have changed. 825 // So reset all images. 826 if (yLayer.data != null) { 827 for (ImageEntry ie: yLayer.data) { 828 ie.discardTmp(); 829 } 830 } 831 832 // Construct a list of images that have a date, and sort them on the date. 833 List<ImageEntry> dateImgLst = getSortedImgList(); 834 // Create a temporary copy for each image 835 for (ImageEntry ie : dateImgLst) { 836 ie.createTmp(); 837 ie.tmp.setPos(null); 838 } 839 840 GpxDataWrapper selGpx = selectedGPX(false); 841 if (selGpx == null) 842 return tr("No gpx selected"); 843 844 final long offset_ms = ((long) (timezone * 3600) + delta) * 1000; // in milliseconds 845 lastNumMatched = matchGpxTrack(dateImgLst, selGpx.data, offset_ms); 846 847 return trn("<html>Matched <b>{0}</b> of <b>{1}</b> photo to GPX track.</html>", 848 "<html>Matched <b>{0}</b> of <b>{1}</b> photos to GPX track.</html>", 849 dateImgLst.size(), lastNumMatched, dateImgLst.size()); 850 } 851 } 852 853 private final transient RepaintTheMapListener repaintTheMap = new RepaintTheMapListener(); 854 855 private class RepaintTheMapListener implements FocusListener { 856 @Override 857 public void focusGained(FocusEvent e) { // do nothing 858 } 859 860 @Override 861 public void focusLost(FocusEvent e) { 862 yLayer.updateBufferAndRepaint(); 863 } 864 } 865 866 /** 867 * Presents dialog with sliders for manual adjust. 868 */ 869 private class AdjustActionListener implements ActionListener { 870 871 @Override 872 public void actionPerformed(ActionEvent arg0) { 873 874 long diff = delta + Math.round(timezone*60*60); 875 876 double diffInH = (double) diff/(60*60); // hours 877 878 // Find day difference 879 final int dayOffset = (int) Math.round(diffInH / 24); // days 880 double tmz = diff - dayOffset*24*60*60L; // seconds 881 882 // In hours, rounded to two decimal places 883 tmz = (double) Math.round(tmz*100/(60*60)) / 100; 884 885 // Due to imprecise clocks we might get a "+3:28" timezone, which should obviously be 3:30 with 886 // -2 minutes offset. This determines the real timezone and finds offset. 887 double fixTimezone = (double) Math.round(tmz * 2)/2; // hours, rounded to one decimal place 888 int offset = (int) Math.round(diff - fixTimezone*60*60) - dayOffset*24*60*60; // seconds 889 890 // Info Labels 891 final JLabel lblMatches = new JLabel(); 892 893 // Timezone Slider 894 // The slider allows to switch timezon from -12:00 to 12:00 in 30 minutes steps. Therefore the range is -24 to 24. 895 final JLabel lblTimezone = new JLabel(); 896 final JSlider sldTimezone = new JSlider(-24, 24, 0); 897 sldTimezone.setPaintLabels(true); 898 Dictionary<Integer, JLabel> labelTable = new Hashtable<>(); 899 // CHECKSTYLE.OFF: ParenPad 900 labelTable.put(-24, new JLabel("-12:00")); 901 labelTable.put(-12, new JLabel( "-6:00")); 902 labelTable.put( 0, new JLabel( "0:00")); 903 labelTable.put( 12, new JLabel( "6:00")); 904 labelTable.put( 24, new JLabel( "12:00")); 905 // CHECKSTYLE.ON: ParenPad 906 sldTimezone.setLabelTable(labelTable); 907 908 // Minutes Slider 909 final JLabel lblMinutes = new JLabel(); 910 final JSlider sldMinutes = new JSlider(-15, 15, 0); 911 sldMinutes.setPaintLabels(true); 912 sldMinutes.setMajorTickSpacing(5); 913 914 // Seconds slider 915 final JLabel lblSeconds = new JLabel(); 916 final JSlider sldSeconds = new JSlider(-60, 60, 0); 917 sldSeconds.setPaintLabels(true); 918 sldSeconds.setMajorTickSpacing(30); 919 920 // This is called whenever one of the sliders is moved. 921 // It updates the labels and also calls the "match photos" code 922 class SliderListener implements ChangeListener { 923 @Override 924 public void stateChanged(ChangeEvent e) { 925 // parse slider position into real timezone 926 double tz = Math.abs(sldTimezone.getValue()); 927 String zone = tz % 2 == 0 928 ? (int) Math.floor(tz/2) + ":00" 929 : (int) Math.floor(tz/2) + ":30"; 930 if (sldTimezone.getValue() < 0) { 931 zone = '-' + zone; 932 } 933 934 lblTimezone.setText(tr("Timezone: {0}", zone)); 935 lblMinutes.setText(tr("Minutes: {0}", sldMinutes.getValue())); 936 lblSeconds.setText(tr("Seconds: {0}", sldSeconds.getValue())); 937 938 try { 939 timezone = parseTimezone(zone); 940 } catch (ParseException pe) { 941 throw new RuntimeException(pe); 942 } 943 delta = sldMinutes.getValue()*60 + sldSeconds.getValue(); 944 945 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater); 946 tfOffset.getDocument().removeDocumentListener(statusBarUpdater); 947 948 tfTimezone.setText(formatTimezone(timezone)); 949 tfOffset.setText(Long.toString(delta + 24*60*60L*dayOffset)); // add the day offset to the offset field 950 951 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 952 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 953 954 lblMatches.setText(statusBarText.getText() + "<br>" + trn("(Time difference of {0} day)", 955 "Time difference of {0} days", Math.abs(dayOffset), Math.abs(dayOffset))); 956 957 statusBarUpdater.updateStatusBar(); 958 yLayer.updateBufferAndRepaint(); 959 } 960 } 961 962 // Put everything together 963 JPanel p = new JPanel(new GridBagLayout()); 964 p.setPreferredSize(new Dimension(400, 230)); 965 p.add(lblMatches, GBC.eol().fill()); 966 p.add(lblTimezone, GBC.eol().fill()); 967 p.add(sldTimezone, GBC.eol().fill().insets(0, 0, 0, 10)); 968 p.add(lblMinutes, GBC.eol().fill()); 969 p.add(sldMinutes, GBC.eol().fill().insets(0, 0, 0, 10)); 970 p.add(lblSeconds, GBC.eol().fill()); 971 p.add(sldSeconds, GBC.eol().fill()); 972 973 // If there's an error in the calculation the found values 974 // will be off range for the sliders. Catch this error 975 // and inform the user about it. 976 try { 977 sldTimezone.setValue((int) (fixTimezone*2)); 978 sldMinutes.setValue(offset / 60); 979 sldSeconds.setValue(offset % 60); 980 } catch (Exception e) { 981 JOptionPane.showMessageDialog(Main.parent, 982 tr("An error occurred while trying to match the photos to the GPX track." 983 +" You can adjust the sliders to manually match the photos."), 984 tr("Matching photos to track failed"), 985 JOptionPane.WARNING_MESSAGE); 986 } 987 988 // Call the sliderListener once manually so labels get adjusted 989 new SliderListener().stateChanged(null); 990 // Listeners added here, otherwise it tries to match three times 991 // (when setting the default values) 992 sldTimezone.addChangeListener(new SliderListener()); 993 sldMinutes.addChangeListener(new SliderListener()); 994 sldSeconds.addChangeListener(new SliderListener()); 995 996 // There is no way to cancel this dialog, all changes get applied 997 // immediately. Therefore "Close" is marked with an "OK" icon. 998 // Settings are only saved temporarily to the layer. 999 new ExtendedDialog(Main.parent, 1000 tr("Adjust timezone and offset"), 1001 new String[] {tr("Close")}). 1002 setContent(p).setButtonIcons(new String[] {"ok"}).showDialog(); 1003 } 1004 } 1005 1006 private class AutoGuessActionListener implements ActionListener { 1007 1008 @Override 1009 public void actionPerformed(ActionEvent arg0) { 1010 GpxDataWrapper gpxW = selectedGPX(true); 1011 if (gpxW == null) 1012 return; 1013 GpxData gpx = gpxW.data; 1014 1015 List<ImageEntry> imgs = getSortedImgList(); 1016 PrimaryDateParser dateParser = new PrimaryDateParser(); 1017 1018 // no images found, exit 1019 if (imgs.isEmpty()) { 1020 JOptionPane.showMessageDialog(Main.parent, 1021 tr("The selected photos do not contain time information."), 1022 tr("Photos do not contain time information"), JOptionPane.WARNING_MESSAGE); 1023 return; 1024 } 1025 1026 // Init variables 1027 long firstExifDate = imgs.get(0).getExifTime().getTime()/1000; 1028 1029 long firstGPXDate = -1; 1030 // Finds first GPX point 1031 outer: for (GpxTrack trk : gpx.tracks) { 1032 for (GpxTrackSegment segment : trk.getSegments()) { 1033 for (WayPoint curWp : segment.getWayPoints()) { 1034 String curDateWpStr = curWp.getString(GpxConstants.PT_TIME); 1035 if (curDateWpStr == null) { 1036 continue; 1037 } 1038 1039 try { 1040 firstGPXDate = dateParser.parse(curDateWpStr).getTime()/1000; 1041 break outer; 1042 } catch (Exception e) { 1043 Main.warn(e); 1044 } 1045 } 1046 } 1047 } 1048 1049 // No GPX timestamps found, exit 1050 if (firstGPXDate < 0) { 1051 JOptionPane.showMessageDialog(Main.parent, 1052 tr("The selected GPX track does not contain timestamps. Please select another one."), 1053 tr("GPX Track has no time information"), JOptionPane.WARNING_MESSAGE); 1054 return; 1055 } 1056 1057 // seconds 1058 long diff = firstExifDate - firstGPXDate; 1059 1060 double diffInH = (double) diff/(60*60); // hours 1061 1062 // Find day difference 1063 int dayOffset = (int) Math.round(diffInH / 24); // days 1064 double tz = diff - dayOffset*24*60*60L; // seconds 1065 1066 // In hours, rounded to two decimal places 1067 tz = (double) Math.round(tz*100/(60*60)) / 100; 1068 1069 // Due to imprecise clocks we might get a "+3:28" timezone, which should obviously be 3:30 with 1070 // -2 minutes offset. This determines the real timezone and finds offset. 1071 timezone = (double) Math.round(tz * 2)/2; // hours, rounded to one decimal place 1072 delta = Math.round(diff - timezone*60*60); // seconds 1073 1074 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater); 1075 tfOffset.getDocument().removeDocumentListener(statusBarUpdater); 1076 1077 tfTimezone.setText(formatTimezone(timezone)); 1078 tfOffset.setText(Long.toString(delta)); 1079 tfOffset.requestFocus(); 1080 1081 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 1082 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 1083 1084 statusBarUpdater.updateStatusBar(); 1085 yLayer.updateBufferAndRepaint(); 1086 } 1087 } 1088 1089 private List<ImageEntry> getSortedImgList() { 1090 return getSortedImgList(cbExifImg.isSelected(), cbTaggedImg.isSelected()); 1091 } 1092 1093 /** 1094 * Returns a list of images that fulfill the given criteria. 1095 * Default setting is to return untagged images, but may be overwritten. 1096 * @param exif also returns images with exif-gps info 1097 * @param tagged also returns tagged images 1098 * @return matching images 1099 */ 1100 private List<ImageEntry> getSortedImgList(boolean exif, boolean tagged) { 1101 if (yLayer.data == null) { 1102 return Collections.emptyList(); 1103 } 1104 List<ImageEntry> dateImgLst = new ArrayList<>(yLayer.data.size()); 1105 for (ImageEntry e : yLayer.data) { 1106 if (!e.hasExifTime()) { 1107 continue; 1108 } 1109 1110 if (e.getExifCoor() != null && !exif) { 1111 continue; 1112 } 1113 1114 if (e.isTagged() && e.getExifCoor() == null && !tagged) { 1115 continue; 1116 } 1117 1118 dateImgLst.add(e); 1119 } 1120 1121 Collections.sort(dateImgLst, new Comparator<ImageEntry>() { 1122 @Override 1123 public int compare(ImageEntry arg0, ImageEntry arg1) { 1124 return arg0.getExifTime().compareTo(arg1.getExifTime()); 1125 } 1126 }); 1127 1128 return dateImgLst; 1129 } 1130 1131 private GpxDataWrapper selectedGPX(boolean complain) { 1132 Object item = cbGpx.getSelectedItem(); 1133 1134 if (item == null || ((GpxDataWrapper) item).file == null) { 1135 if (complain) { 1136 JOptionPane.showMessageDialog(Main.parent, tr("You should select a GPX track"), 1137 tr("No selected GPX track"), JOptionPane.ERROR_MESSAGE); 1138 } 1139 return null; 1140 } 1141 return (GpxDataWrapper) item; 1142 } 1143 1144 /** 1145 * Match a list of photos to a gpx track with a given offset. 1146 * All images need a exifTime attribute and the List must be sorted according to these times. 1147 * @param images images to match 1148 * @param selectedGpx selected GPX data 1149 * @param offset offset 1150 * @return number of matched points 1151 */ 1152 private int matchGpxTrack(List<ImageEntry> images, GpxData selectedGpx, long offset) { 1153 int ret = 0; 1154 1155 PrimaryDateParser dateParser = new PrimaryDateParser(); 1156 1157 for (GpxTrack trk : selectedGpx.tracks) { 1158 for (GpxTrackSegment segment : trk.getSegments()) { 1159 1160 long prevWpTime = 0; 1161 WayPoint prevWp = null; 1162 1163 for (WayPoint curWp : segment.getWayPoints()) { 1164 1165 String curWpTimeStr = curWp.getString(GpxConstants.PT_TIME); 1166 if (curWpTimeStr != null) { 1167 1168 try { 1169 long curWpTime = dateParser.parse(curWpTimeStr).getTime() + offset; 1170 ret += matchPoints(images, prevWp, prevWpTime, curWp, curWpTime, offset); 1171 1172 prevWp = curWp; 1173 prevWpTime = curWpTime; 1174 1175 } catch (ParseException e) { 1176 Main.error("Error while parsing date \"" + curWpTimeStr + '"'); 1177 Main.error(e); 1178 prevWp = null; 1179 prevWpTime = 0; 1180 } 1181 } else { 1182 prevWp = null; 1183 prevWpTime = 0; 1184 } 1185 } 1186 } 1187 } 1188 return ret; 1189 } 1190 1191 private static Double getElevation(WayPoint wp) { 1192 String value = wp.getString(GpxConstants.PT_ELE); 1193 if (value != null) { 1194 try { 1195 return new Double(value); 1196 } catch (NumberFormatException e) { 1197 Main.warn(e); 1198 } 1199 } 1200 return null; 1201 } 1202 1203 private int matchPoints(List<ImageEntry> images, WayPoint prevWp, long prevWpTime, 1204 WayPoint curWp, long curWpTime, long offset) { 1205 // Time between the track point and the previous one, 5 sec if first point, i.e. photos take 1206 // 5 sec before the first track point can be assumed to be take at the starting position 1207 long interval = prevWpTime > 0 ? Math.abs(curWpTime - prevWpTime) : 5*1000; 1208 int ret = 0; 1209 1210 // i is the index of the timewise last photo that has the same or earlier EXIF time 1211 int i = getLastIndexOfListBefore(images, curWpTime); 1212 1213 // no photos match 1214 if (i < 0) 1215 return 0; 1216 1217 Double speed = null; 1218 Double prevElevation = null; 1219 1220 if (prevWp != null) { 1221 double distance = prevWp.getCoor().greatCircleDistance(curWp.getCoor()); 1222 // This is in km/h, 3.6 * m/s 1223 if (curWpTime > prevWpTime) { 1224 speed = 3600 * distance / (curWpTime - prevWpTime); 1225 } 1226 prevElevation = getElevation(prevWp); 1227 } 1228 1229 Double curElevation = getElevation(curWp); 1230 1231 // First trackpoint, then interval is set to five seconds, i.e. photos up to five seconds 1232 // before the first point will be geotagged with the starting point 1233 if (prevWpTime == 0 || curWpTime <= prevWpTime) { 1234 while (i >= 0) { 1235 final ImageEntry curImg = images.get(i); 1236 long time = curImg.getExifTime().getTime(); 1237 if (time > curWpTime || time < curWpTime - interval) { 1238 break; 1239 } 1240 if (curImg.tmp.getPos() == null) { 1241 curImg.tmp.setPos(curWp.getCoor()); 1242 curImg.tmp.setSpeed(speed); 1243 curImg.tmp.setElevation(curElevation); 1244 curImg.tmp.setGpsTime(new Date(curImg.getExifTime().getTime() - offset)); 1245 curImg.flagNewGpsData(); 1246 ret++; 1247 } 1248 i--; 1249 } 1250 return ret; 1251 } 1252 1253 // This code gives a simple linear interpolation of the coordinates between current and 1254 // previous track point assuming a constant speed in between 1255 while (i >= 0) { 1256 ImageEntry curImg = images.get(i); 1257 long imgTime = curImg.getExifTime().getTime(); 1258 if (imgTime < prevWpTime) { 1259 break; 1260 } 1261 1262 if (curImg.tmp.getPos() == null && prevWp != null) { 1263 // The values of timeDiff are between 0 and 1, it is not seconds but a dimensionless variable 1264 double timeDiff = (double) (imgTime - prevWpTime) / interval; 1265 curImg.tmp.setPos(prevWp.getCoor().interpolate(curWp.getCoor(), timeDiff)); 1266 curImg.tmp.setSpeed(speed); 1267 if (curElevation != null && prevElevation != null) { 1268 curImg.tmp.setElevation(prevElevation + (curElevation - prevElevation) * timeDiff); 1269 } 1270 curImg.tmp.setGpsTime(new Date(curImg.getExifTime().getTime() - offset)); 1271 curImg.flagNewGpsData(); 1272 1273 ret++; 1274 } 1275 i--; 1276 } 1277 return ret; 1278 } 1279 1280 private static int getLastIndexOfListBefore(List<ImageEntry> images, long searchedTime) { 1281 int lstSize = images.size(); 1282 1283 // No photos or the first photo taken is later than the search period 1284 if (lstSize == 0 || searchedTime < images.get(0).getExifTime().getTime()) 1285 return -1; 1286 1287 // The search period is later than the last photo 1288 if (searchedTime > images.get(lstSize - 1).getExifTime().getTime()) 1289 return lstSize-1; 1290 1291 // The searched index is somewhere in the middle, do a binary search from the beginning 1292 int curIndex = 0; 1293 int startIndex = 0; 1294 int endIndex = lstSize-1; 1295 while (endIndex - startIndex > 1) { 1296 curIndex = (endIndex + startIndex) / 2; 1297 if (searchedTime > images.get(curIndex).getExifTime().getTime()) { 1298 startIndex = curIndex; 1299 } else { 1300 endIndex = curIndex; 1301 } 1302 } 1303 if (searchedTime < images.get(endIndex).getExifTime().getTime()) 1304 return startIndex; 1305 1306 // This final loop is to check if photos with the exact same EXIF time follows 1307 while ((endIndex < (lstSize-1)) && (images.get(endIndex).getExifTime().getTime() 1308 == images.get(endIndex + 1).getExifTime().getTime())) { 1309 endIndex++; 1310 } 1311 return endIndex; 1312 } 1313 1314 private static String formatTimezone(double timezone) { 1315 StringBuilder ret = new StringBuilder(); 1316 1317 if (timezone < 0) { 1318 ret.append('-'); 1319 timezone = -timezone; 1320 } else { 1321 ret.append('+'); 1322 } 1323 ret.append((long) timezone).append(':'); 1324 int minutes = (int) ((timezone % 1) * 60); 1325 if (minutes < 10) { 1326 ret.append('0'); 1327 } 1328 ret.append(minutes); 1329 1330 return ret.toString(); 1331 } 1332 1333 private static double parseTimezone(String timezone) throws ParseException { 1334 1335 if (timezone.isEmpty()) 1336 return 0; 1337 1338 String error = tr("Error while parsing timezone.\nExpected format: {0}", "+H:MM"); 1339 1340 char sgnTimezone = '+'; 1341 StringBuilder hTimezone = new StringBuilder(); 1342 StringBuilder mTimezone = new StringBuilder(); 1343 int state = 1; // 1=start/sign, 2=hours, 3=minutes. 1344 for (int i = 0; i < timezone.length(); i++) { 1345 char c = timezone.charAt(i); 1346 switch (c) { 1347 case ' ' : 1348 if (state != 2 || hTimezone.length() != 0) 1349 throw new ParseException(error, i); 1350 break; 1351 case '+' : 1352 case '-' : 1353 if (state == 1) { 1354 sgnTimezone = c; 1355 state = 2; 1356 } else 1357 throw new ParseException(error, i); 1358 break; 1359 case ':' : 1360 case '.' : 1361 if (state == 2) { 1362 state = 3; 1363 } else 1364 throw new ParseException(error, i); 1365 break; 1366 case '0' : case '1' : case '2' : case '3' : case '4' : 1367 case '5' : case '6' : case '7' : case '8' : case '9' : 1368 switch(state) { 1369 case 1 : 1370 case 2 : 1371 state = 2; 1372 hTimezone.append(c); 1373 break; 1374 case 3 : 1375 mTimezone.append(c); 1376 break; 1377 default : 1378 throw new ParseException(error, i); 1379 } 1380 break; 1381 default : 1382 throw new ParseException(error, i); 1383 } 1384 } 1385 1386 int h = 0; 1387 int m = 0; 1388 try { 1389 h = Integer.parseInt(hTimezone.toString()); 1390 if (mTimezone.length() > 0) { 1391 m = Integer.parseInt(mTimezone.toString()); 1392 } 1393 } catch (NumberFormatException nfe) { 1394 // Invalid timezone 1395 throw new ParseException(error, 0); 1396 } 1397 1398 if (h > 12 || m > 59) 1399 throw new ParseException(error, 0); 1400 else 1401 return (h + m / 60.0) * (sgnTimezone == '-' ? -1 : 1); 1402 } 1403 1404 private static long parseOffset(String offset) throws ParseException { 1405 String error = tr("Error while parsing offset.\nExpected format: {0}", "number"); 1406 1407 if (!offset.isEmpty()) { 1408 try { 1409 if (offset.startsWith("+")) { 1410 offset = offset.substring(1); 1411 } 1412 return Long.parseLong(offset); 1413 } catch (NumberFormatException nfe) { 1414 throw new ParseException(error, 0); 1415 } 1416 } else { 1417 return 0; 1418 } 1419 } 1420}