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