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