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