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 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.tmp = null; 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 String name; 212 private GpxData data; 213 private 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 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 transient StatusBarUpdater statusBarUpdater = new StatusBarUpdater(false); 776 private transient StatusBarUpdater statusBarUpdaterWithRepaint = new StatusBarUpdater(true); 777 778 private class StatusBarUpdater implements DocumentListener, ItemListener, ActionListener { 779 private 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.tmp = null; 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.cleanTmp(); 837 } 838 839 GpxDataWrapper selGpx = selectedGPX(false); 840 if (selGpx == null) 841 return tr("No gpx selected"); 842 843 final long offset_ms = ((long) (timezone * 3600) + delta) * 1000; // in milliseconds 844 lastNumMatched = matchGpxTrack(dateImgLst, selGpx.data, offset_ms); 845 846 return trn("<html>Matched <b>{0}</b> of <b>{1}</b> photo to GPX track.</html>", 847 "<html>Matched <b>{0}</b> of <b>{1}</b> photos to GPX track.</html>", 848 dateImgLst.size(), lastNumMatched, dateImgLst.size()); 849 } 850 } 851 852 private transient RepaintTheMapListener repaintTheMap = new RepaintTheMapListener(); 853 854 private class RepaintTheMapListener implements FocusListener { 855 @Override 856 public void focusGained(FocusEvent e) { // do nothing 857 } 858 859 @Override 860 public void focusLost(FocusEvent e) { 861 yLayer.updateBufferAndRepaint(); 862 } 863 } 864 865 /** 866 * Presents dialog with sliders for manual adjust. 867 */ 868 private class AdjustActionListener implements ActionListener { 869 870 @Override 871 public void actionPerformed(ActionEvent arg0) { 872 873 long diff = delta + Math.round(timezone*60*60); 874 875 double diffInH = (double) diff/(60*60); // hours 876 877 // Find day difference 878 final int dayOffset = (int) Math.round(diffInH / 24); // days 879 double tmz = diff - dayOffset*24*60*60L; // seconds 880 881 // In hours, rounded to two decimal places 882 tmz = (double) Math.round(tmz*100/(60*60)) / 100; 883 884 // Due to imprecise clocks we might get a "+3:28" timezone, which should obviously be 3:30 with 885 // -2 minutes offset. This determines the real timezone and finds offset. 886 double fixTimezone = (double) Math.round(tmz * 2)/2; // hours, rounded to one decimal place 887 int offset = (int) Math.round(diff - fixTimezone*60*60) - dayOffset*24*60*60; // seconds 888 889 // Info Labels 890 final JLabel lblMatches = new JLabel(); 891 892 // Timezone Slider 893 // The slider allows to switch timezon from -12:00 to 12:00 in 30 minutes steps. Therefore the range is -24 to 24. 894 final JLabel lblTimezone = new JLabel(); 895 final JSlider sldTimezone = new JSlider(-24, 24, 0); 896 sldTimezone.setPaintLabels(true); 897 Dictionary<Integer, JLabel> labelTable = new Hashtable<>(); 898 // CHECKSTYLE.OFF: ParenPad 899 labelTable.put(-24, new JLabel("-12:00")); 900 labelTable.put(-12, new JLabel( "-6:00")); 901 labelTable.put( 0, new JLabel( "0:00")); 902 labelTable.put( 12, new JLabel( "6:00")); 903 labelTable.put( 24, new JLabel( "12:00")); 904 // CHECKSTYLE.ON: ParenPad 905 sldTimezone.setLabelTable(labelTable); 906 907 // Minutes Slider 908 final JLabel lblMinutes = new JLabel(); 909 final JSlider sldMinutes = new JSlider(-15, 15, 0); 910 sldMinutes.setPaintLabels(true); 911 sldMinutes.setMajorTickSpacing(5); 912 913 // Seconds slider 914 final JLabel lblSeconds = new JLabel(); 915 final JSlider sldSeconds = new JSlider(-60, 60, 0); 916 sldSeconds.setPaintLabels(true); 917 sldSeconds.setMajorTickSpacing(30); 918 919 // This is called whenever one of the sliders is moved. 920 // It updates the labels and also calls the "match photos" code 921 class SliderListener implements ChangeListener { 922 @Override 923 public void stateChanged(ChangeEvent e) { 924 // parse slider position into real timezone 925 double tz = Math.abs(sldTimezone.getValue()); 926 String zone = tz % 2 == 0 927 ? (int) Math.floor(tz/2) + ":00" 928 : (int) Math.floor(tz/2) + ":30"; 929 if (sldTimezone.getValue() < 0) { 930 zone = '-' + zone; 931 } 932 933 lblTimezone.setText(tr("Timezone: {0}", zone)); 934 lblMinutes.setText(tr("Minutes: {0}", sldMinutes.getValue())); 935 lblSeconds.setText(tr("Seconds: {0}", sldSeconds.getValue())); 936 937 try { 938 timezone = parseTimezone(zone); 939 } catch (ParseException pe) { 940 throw new RuntimeException(pe); 941 } 942 delta = sldMinutes.getValue()*60 + sldSeconds.getValue(); 943 944 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater); 945 tfOffset.getDocument().removeDocumentListener(statusBarUpdater); 946 947 tfTimezone.setText(formatTimezone(timezone)); 948 tfOffset.setText(Long.toString(delta + 24*60*60L*dayOffset)); // add the day offset to the offset field 949 950 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 951 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 952 953 lblMatches.setText(statusBarText.getText() + "<br>" + trn("(Time difference of {0} day)", 954 "Time difference of {0} days", Math.abs(dayOffset), Math.abs(dayOffset))); 955 956 statusBarUpdater.updateStatusBar(); 957 yLayer.updateBufferAndRepaint(); 958 } 959 } 960 961 // Put everything together 962 JPanel p = new JPanel(new GridBagLayout()); 963 p.setPreferredSize(new Dimension(400, 230)); 964 p.add(lblMatches, GBC.eol().fill()); 965 p.add(lblTimezone, GBC.eol().fill()); 966 p.add(sldTimezone, GBC.eol().fill().insets(0, 0, 0, 10)); 967 p.add(lblMinutes, GBC.eol().fill()); 968 p.add(sldMinutes, GBC.eol().fill().insets(0, 0, 0, 10)); 969 p.add(lblSeconds, GBC.eol().fill()); 970 p.add(sldSeconds, GBC.eol().fill()); 971 972 // If there's an error in the calculation the found values 973 // will be off range for the sliders. Catch this error 974 // and inform the user about it. 975 try { 976 sldTimezone.setValue((int) (fixTimezone*2)); 977 sldMinutes.setValue(offset / 60); 978 sldSeconds.setValue(offset % 60); 979 } catch (Exception e) { 980 JOptionPane.showMessageDialog(Main.parent, 981 tr("An error occurred while trying to match the photos to the GPX track." 982 +" You can adjust the sliders to manually match the photos."), 983 tr("Matching photos to track failed"), 984 JOptionPane.WARNING_MESSAGE); 985 } 986 987 // Call the sliderListener once manually so labels get adjusted 988 new SliderListener().stateChanged(null); 989 // Listeners added here, otherwise it tries to match three times 990 // (when setting the default values) 991 sldTimezone.addChangeListener(new SliderListener()); 992 sldMinutes.addChangeListener(new SliderListener()); 993 sldSeconds.addChangeListener(new SliderListener()); 994 995 // There is no way to cancel this dialog, all changes get applied 996 // immediately. Therefore "Close" is marked with an "OK" icon. 997 // Settings are only saved temporarily to the layer. 998 new ExtendedDialog(Main.parent, 999 tr("Adjust timezone and offset"), 1000 new String[] {tr("Close")}). 1001 setContent(p).setButtonIcons(new String[] {"ok"}).showDialog(); 1002 } 1003 } 1004 1005 private class AutoGuessActionListener implements ActionListener { 1006 1007 @Override 1008 public void actionPerformed(ActionEvent arg0) { 1009 GpxDataWrapper gpxW = selectedGPX(true); 1010 if (gpxW == null) 1011 return; 1012 GpxData gpx = gpxW.data; 1013 1014 List<ImageEntry> imgs = getSortedImgList(); 1015 PrimaryDateParser dateParser = new PrimaryDateParser(); 1016 1017 // no images found, exit 1018 if (imgs.isEmpty()) { 1019 JOptionPane.showMessageDialog(Main.parent, 1020 tr("The selected photos do not contain time information."), 1021 tr("Photos do not contain time information"), JOptionPane.WARNING_MESSAGE); 1022 return; 1023 } 1024 1025 // Init variables 1026 long firstExifDate = imgs.get(0).getExifTime().getTime()/1000; 1027 1028 long firstGPXDate = -1; 1029 // Finds first GPX point 1030 outer: for (GpxTrack trk : gpx.tracks) { 1031 for (GpxTrackSegment segment : trk.getSegments()) { 1032 for (WayPoint curWp : segment.getWayPoints()) { 1033 String curDateWpStr = curWp.getString(GpxConstants.PT_TIME); 1034 if (curDateWpStr == null) { 1035 continue; 1036 } 1037 1038 try { 1039 firstGPXDate = dateParser.parse(curDateWpStr).getTime()/1000; 1040 break outer; 1041 } catch (Exception e) { 1042 Main.warn(e); 1043 } 1044 } 1045 } 1046 } 1047 1048 // No GPX timestamps found, exit 1049 if (firstGPXDate < 0) { 1050 JOptionPane.showMessageDialog(Main.parent, 1051 tr("The selected GPX track does not contain timestamps. Please select another one."), 1052 tr("GPX Track has no time information"), JOptionPane.WARNING_MESSAGE); 1053 return; 1054 } 1055 1056 // seconds 1057 long diff = firstExifDate - firstGPXDate; 1058 1059 double diffInH = (double) diff/(60*60); // hours 1060 1061 // Find day difference 1062 int dayOffset = (int) Math.round(diffInH / 24); // days 1063 double tz = diff - dayOffset*24*60*60L; // seconds 1064 1065 // In hours, rounded to two decimal places 1066 tz = (double) Math.round(tz*100/(60*60)) / 100; 1067 1068 // Due to imprecise clocks we might get a "+3:28" timezone, which should obviously be 3:30 with 1069 // -2 minutes offset. This determines the real timezone and finds offset. 1070 timezone = (double) Math.round(tz * 2)/2; // hours, rounded to one decimal place 1071 delta = Math.round(diff - timezone*60*60); // seconds 1072 1073 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater); 1074 tfOffset.getDocument().removeDocumentListener(statusBarUpdater); 1075 1076 tfTimezone.setText(formatTimezone(timezone)); 1077 tfOffset.setText(Long.toString(delta)); 1078 tfOffset.requestFocus(); 1079 1080 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 1081 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 1082 1083 statusBarUpdater.updateStatusBar(); 1084 yLayer.updateBufferAndRepaint(); 1085 } 1086 } 1087 1088 private List<ImageEntry> getSortedImgList() { 1089 return getSortedImgList(cbExifImg.isSelected(), cbTaggedImg.isSelected()); 1090 } 1091 1092 /** 1093 * Returns a list of images that fulfill the given criteria. 1094 * Default setting is to return untagged images, but may be overwritten. 1095 * @param exif also returns images with exif-gps info 1096 * @param tagged also returns tagged images 1097 * @return matching images 1098 */ 1099 private List<ImageEntry> getSortedImgList(boolean exif, boolean tagged) { 1100 if (yLayer.data == null) { 1101 return Collections.emptyList(); 1102 } 1103 List<ImageEntry> dateImgLst = new ArrayList<>(yLayer.data.size()); 1104 for (ImageEntry e : yLayer.data) { 1105 if (!e.hasExifTime()) { 1106 continue; 1107 } 1108 1109 if (e.getExifCoor() != null && !exif) { 1110 continue; 1111 } 1112 1113 if (e.isTagged() && e.getExifCoor() == null && !tagged) { 1114 continue; 1115 } 1116 1117 dateImgLst.add(e); 1118 } 1119 1120 Collections.sort(dateImgLst, new Comparator<ImageEntry>() { 1121 @Override 1122 public int compare(ImageEntry arg0, ImageEntry arg1) { 1123 return arg0.getExifTime().compareTo(arg1.getExifTime()); 1124 } 1125 }); 1126 1127 return dateImgLst; 1128 } 1129 1130 private GpxDataWrapper selectedGPX(boolean complain) { 1131 Object item = cbGpx.getSelectedItem(); 1132 1133 if (item == null || ((GpxDataWrapper) item).file == null) { 1134 if (complain) { 1135 JOptionPane.showMessageDialog(Main.parent, tr("You should select a GPX track"), 1136 tr("No selected GPX track"), JOptionPane.ERROR_MESSAGE); 1137 } 1138 return null; 1139 } 1140 return (GpxDataWrapper) item; 1141 } 1142 1143 /** 1144 * Match a list of photos to a gpx track with a given offset. 1145 * All images need a exifTime attribute and the List must be sorted according to these times. 1146 */ 1147 private int matchGpxTrack(List<ImageEntry> images, GpxData selectedGpx, long offset) { 1148 int ret = 0; 1149 1150 PrimaryDateParser dateParser = new PrimaryDateParser(); 1151 1152 for (GpxTrack trk : selectedGpx.tracks) { 1153 for (GpxTrackSegment segment : trk.getSegments()) { 1154 1155 long prevWpTime = 0; 1156 WayPoint prevWp = null; 1157 1158 for (WayPoint curWp : segment.getWayPoints()) { 1159 1160 String curWpTimeStr = curWp.getString(GpxConstants.PT_TIME); 1161 if (curWpTimeStr != null) { 1162 1163 try { 1164 long curWpTime = dateParser.parse(curWpTimeStr).getTime() + offset; 1165 ret += matchPoints(images, prevWp, prevWpTime, curWp, curWpTime, offset); 1166 1167 prevWp = curWp; 1168 prevWpTime = curWpTime; 1169 1170 } catch (ParseException e) { 1171 Main.error("Error while parsing date \"" + curWpTimeStr + '"'); 1172 Main.error(e); 1173 prevWp = null; 1174 prevWpTime = 0; 1175 } 1176 } else { 1177 prevWp = null; 1178 prevWpTime = 0; 1179 } 1180 } 1181 } 1182 } 1183 return ret; 1184 } 1185 1186 private static Double getElevation(WayPoint wp) { 1187 String value = wp.getString(GpxConstants.PT_ELE); 1188 if (value != null) { 1189 try { 1190 return new Double(value); 1191 } catch (NumberFormatException e) { 1192 Main.warn(e); 1193 } 1194 } 1195 return null; 1196 } 1197 1198 private int matchPoints(List<ImageEntry> images, WayPoint prevWp, long prevWpTime, 1199 WayPoint curWp, long curWpTime, long offset) { 1200 // Time between the track point and the previous one, 5 sec if first point, i.e. photos take 1201 // 5 sec before the first track point can be assumed to be take at the starting position 1202 long interval = prevWpTime > 0 ? Math.abs(curWpTime - prevWpTime) : 5*1000; 1203 int ret = 0; 1204 1205 // i is the index of the timewise last photo that has the same or earlier EXIF time 1206 int i = getLastIndexOfListBefore(images, curWpTime); 1207 1208 // no photos match 1209 if (i < 0) 1210 return 0; 1211 1212 Double speed = null; 1213 Double prevElevation = null; 1214 1215 if (prevWp != null) { 1216 double distance = prevWp.getCoor().greatCircleDistance(curWp.getCoor()); 1217 // This is in km/h, 3.6 * m/s 1218 if (curWpTime > prevWpTime) { 1219 speed = 3600 * distance / (curWpTime - prevWpTime); 1220 } 1221 prevElevation = getElevation(prevWp); 1222 } 1223 1224 Double curElevation = getElevation(curWp); 1225 1226 // First trackpoint, then interval is set to five seconds, i.e. photos up to five seconds 1227 // before the first point will be geotagged with the starting point 1228 if (prevWpTime == 0 || curWpTime <= prevWpTime) { 1229 while (i >= 0) { 1230 final ImageEntry curImg = images.get(i); 1231 long time = curImg.getExifTime().getTime(); 1232 if (time > curWpTime || time < curWpTime - interval) { 1233 break; 1234 } 1235 if (curImg.tmp.getPos() == null) { 1236 curImg.tmp.setPos(curWp.getCoor()); 1237 curImg.tmp.setSpeed(speed); 1238 curImg.tmp.setElevation(curElevation); 1239 curImg.tmp.setGpsTime(new Date(curImg.getExifTime().getTime() - offset)); 1240 curImg.flagNewGpsData(); 1241 ret++; 1242 } 1243 i--; 1244 } 1245 return ret; 1246 } 1247 1248 // This code gives a simple linear interpolation of the coordinates between current and 1249 // previous track point assuming a constant speed in between 1250 while (i >= 0) { 1251 ImageEntry curImg = images.get(i); 1252 long imgTime = curImg.getExifTime().getTime(); 1253 if (imgTime < prevWpTime) { 1254 break; 1255 } 1256 1257 if (curImg.tmp.getPos() == null && prevWp != null) { 1258 // The values of timeDiff are between 0 and 1, it is not seconds but a dimensionless variable 1259 double timeDiff = (double) (imgTime - prevWpTime) / interval; 1260 curImg.tmp.setPos(prevWp.getCoor().interpolate(curWp.getCoor(), timeDiff)); 1261 curImg.tmp.setSpeed(speed); 1262 if (curElevation != null && prevElevation != null) { 1263 curImg.tmp.setElevation(prevElevation + (curElevation - prevElevation) * timeDiff); 1264 } 1265 curImg.tmp.setGpsTime(new Date(curImg.getExifTime().getTime() - offset)); 1266 curImg.flagNewGpsData(); 1267 1268 ret++; 1269 } 1270 i--; 1271 } 1272 return ret; 1273 } 1274 1275 private static int getLastIndexOfListBefore(List<ImageEntry> images, long searchedTime) { 1276 int lstSize = images.size(); 1277 1278 // No photos or the first photo taken is later than the search period 1279 if (lstSize == 0 || searchedTime < images.get(0).getExifTime().getTime()) 1280 return -1; 1281 1282 // The search period is later than the last photo 1283 if (searchedTime > images.get(lstSize - 1).getExifTime().getTime()) 1284 return lstSize-1; 1285 1286 // The searched index is somewhere in the middle, do a binary search from the beginning 1287 int curIndex = 0; 1288 int startIndex = 0; 1289 int endIndex = lstSize-1; 1290 while (endIndex - startIndex > 1) { 1291 curIndex = (endIndex + startIndex) / 2; 1292 if (searchedTime > images.get(curIndex).getExifTime().getTime()) { 1293 startIndex = curIndex; 1294 } else { 1295 endIndex = curIndex; 1296 } 1297 } 1298 if (searchedTime < images.get(endIndex).getExifTime().getTime()) 1299 return startIndex; 1300 1301 // This final loop is to check if photos with the exact same EXIF time follows 1302 while ((endIndex < (lstSize-1)) && (images.get(endIndex).getExifTime().getTime() 1303 == images.get(endIndex + 1).getExifTime().getTime())) { 1304 endIndex++; 1305 } 1306 return endIndex; 1307 } 1308 1309 private static String formatTimezone(double timezone) { 1310 StringBuilder ret = new StringBuilder(); 1311 1312 if (timezone < 0) { 1313 ret.append('-'); 1314 timezone = -timezone; 1315 } else { 1316 ret.append('+'); 1317 } 1318 ret.append((long) timezone).append(':'); 1319 int minutes = (int) ((timezone % 1) * 60); 1320 if (minutes < 10) { 1321 ret.append('0'); 1322 } 1323 ret.append(minutes); 1324 1325 return ret.toString(); 1326 } 1327 1328 private static double parseTimezone(String timezone) throws ParseException { 1329 1330 if (timezone.isEmpty()) 1331 return 0; 1332 1333 String error = tr("Error while parsing timezone.\nExpected format: {0}", "+H:MM"); 1334 1335 char sgnTimezone = '+'; 1336 StringBuilder hTimezone = new StringBuilder(); 1337 StringBuilder mTimezone = new StringBuilder(); 1338 int state = 1; // 1=start/sign, 2=hours, 3=minutes. 1339 for (int i = 0; i < timezone.length(); i++) { 1340 char c = timezone.charAt(i); 1341 switch (c) { 1342 case ' ' : 1343 if (state != 2 || hTimezone.length() != 0) 1344 throw new ParseException(error, i); 1345 break; 1346 case '+' : 1347 case '-' : 1348 if (state == 1) { 1349 sgnTimezone = c; 1350 state = 2; 1351 } else 1352 throw new ParseException(error, i); 1353 break; 1354 case ':' : 1355 case '.' : 1356 if (state == 2) { 1357 state = 3; 1358 } else 1359 throw new ParseException(error, i); 1360 break; 1361 case '0' : case '1' : case '2' : case '3' : case '4' : 1362 case '5' : case '6' : case '7' : case '8' : case '9' : 1363 switch(state) { 1364 case 1 : 1365 case 2 : 1366 state = 2; 1367 hTimezone.append(c); 1368 break; 1369 case 3 : 1370 mTimezone.append(c); 1371 break; 1372 default : 1373 throw new ParseException(error, i); 1374 } 1375 break; 1376 default : 1377 throw new ParseException(error, i); 1378 } 1379 } 1380 1381 int h = 0; 1382 int m = 0; 1383 try { 1384 h = Integer.parseInt(hTimezone.toString()); 1385 if (mTimezone.length() > 0) { 1386 m = Integer.parseInt(mTimezone.toString()); 1387 } 1388 } catch (NumberFormatException nfe) { 1389 // Invalid timezone 1390 throw new ParseException(error, 0); 1391 } 1392 1393 if (h > 12 || m > 59) 1394 throw new ParseException(error, 0); 1395 else 1396 return (h + m / 60.0) * (sgnTimezone == '-' ? -1 : 1); 1397 } 1398 1399 private static long parseOffset(String offset) throws ParseException { 1400 String error = tr("Error while parsing offset.\nExpected format: {0}", "number"); 1401 1402 if (!offset.isEmpty()) { 1403 try { 1404 if (offset.startsWith("+")) { 1405 offset = offset.substring(1); 1406 } 1407 return Long.parseLong(offset); 1408 } catch (NumberFormatException nfe) { 1409 throw new ParseException(error, 0); 1410 } 1411 } else { 1412 return 0; 1413 } 1414 } 1415}