001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer.geoimage; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005import static org.openstreetmap.josm.tools.I18n.trn; 006 007import java.awt.BorderLayout; 008import java.awt.Component; 009import java.awt.Cursor; 010import java.awt.Dimension; 011import java.awt.FlowLayout; 012import java.awt.GraphicsEnvironment; 013import java.awt.GridBagConstraints; 014import java.awt.GridBagLayout; 015import java.awt.event.ActionEvent; 016import java.awt.event.ActionListener; 017import java.awt.event.FocusEvent; 018import java.awt.event.FocusListener; 019import java.awt.event.ItemEvent; 020import java.awt.event.ItemListener; 021import java.awt.event.WindowAdapter; 022import java.awt.event.WindowEvent; 023import java.io.File; 024import java.io.IOException; 025import java.io.InputStream; 026import java.text.DateFormat; 027import java.text.ParseException; 028import java.text.SimpleDateFormat; 029import java.util.ArrayList; 030import java.util.Arrays; 031import java.util.Collections; 032import java.util.Comparator; 033import java.util.Date; 034import java.util.Dictionary; 035import java.util.Hashtable; 036import java.util.List; 037import java.util.Objects; 038import java.util.TimeZone; 039import java.util.concurrent.TimeUnit; 040 041import javax.swing.AbstractAction; 042import javax.swing.AbstractListModel; 043import javax.swing.BorderFactory; 044import javax.swing.JButton; 045import javax.swing.JCheckBox; 046import javax.swing.JComponent; 047import javax.swing.JFileChooser; 048import javax.swing.JLabel; 049import javax.swing.JList; 050import javax.swing.JOptionPane; 051import javax.swing.JPanel; 052import javax.swing.JScrollPane; 053import javax.swing.JSeparator; 054import javax.swing.JSlider; 055import javax.swing.JSpinner; 056import javax.swing.ListSelectionModel; 057import javax.swing.MutableComboBoxModel; 058import javax.swing.SpinnerNumberModel; 059import javax.swing.SwingConstants; 060import javax.swing.border.Border; 061import javax.swing.event.ChangeEvent; 062import javax.swing.event.ChangeListener; 063import javax.swing.event.DocumentEvent; 064import javax.swing.event.DocumentListener; 065 066import org.openstreetmap.josm.actions.DiskAccessAction; 067import org.openstreetmap.josm.actions.ExtensionFileFilter; 068import org.openstreetmap.josm.data.gpx.GpxData; 069import org.openstreetmap.josm.data.gpx.GpxImageCorrelation; 070import org.openstreetmap.josm.data.gpx.GpxTimeOffset; 071import org.openstreetmap.josm.data.gpx.GpxTimezone; 072import org.openstreetmap.josm.data.gpx.GpxTrack; 073import org.openstreetmap.josm.data.gpx.GpxTrackSegment; 074import org.openstreetmap.josm.data.gpx.WayPoint; 075import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 076import org.openstreetmap.josm.gui.ExtendedDialog; 077import org.openstreetmap.josm.gui.MainApplication; 078import org.openstreetmap.josm.gui.io.importexport.GpxImporter; 079import org.openstreetmap.josm.gui.io.importexport.JpgImporter; 080import org.openstreetmap.josm.gui.io.importexport.NMEAImporter; 081import org.openstreetmap.josm.gui.layer.GpxLayer; 082import org.openstreetmap.josm.gui.layer.Layer; 083import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent; 084import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener; 085import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent; 086import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent; 087import org.openstreetmap.josm.gui.widgets.AbstractFileChooser; 088import org.openstreetmap.josm.gui.widgets.FileChooserManager; 089import org.openstreetmap.josm.gui.widgets.JosmComboBox; 090import org.openstreetmap.josm.gui.widgets.JosmTextField; 091import org.openstreetmap.josm.io.Compression; 092import org.openstreetmap.josm.io.GpxReader; 093import org.openstreetmap.josm.io.IGpxReader; 094import org.openstreetmap.josm.io.nmea.NmeaReader; 095import org.openstreetmap.josm.spi.preferences.Config; 096import org.openstreetmap.josm.spi.preferences.IPreferences; 097import org.openstreetmap.josm.tools.GBC; 098import org.openstreetmap.josm.tools.ImageProvider; 099import org.openstreetmap.josm.tools.JosmRuntimeException; 100import org.openstreetmap.josm.tools.Logging; 101import org.openstreetmap.josm.tools.Pair; 102import org.openstreetmap.josm.tools.date.DateUtils; 103import org.xml.sax.SAXException; 104 105/** 106 * This class displays the window to select the GPX file and the offset (timezone + delta). 107 * Then it correlates the images of the layer with that GPX file. 108 * @since 2566 109 */ 110public class CorrelateGpxWithImages extends AbstractAction { 111 112 private static final List<GpxData> loadedGpxData = new ArrayList<>(); 113 114 private final transient GeoImageLayer yLayer; 115 private transient GpxTimezone timezone; 116 private transient GpxTimeOffset delta; 117 private static boolean forceTags; 118 119 /** 120 * Constructs a new {@code CorrelateGpxWithImages} action. 121 * @param layer The image layer 122 */ 123 public CorrelateGpxWithImages(GeoImageLayer layer) { 124 super(tr("Correlate to GPX")); 125 new ImageProvider("dialogs/geoimage/gpx2img").getResource().attachImageIcon(this, true); 126 this.yLayer = layer; 127 MainApplication.getLayerManager().addLayerChangeListener(new GpxLayerAddedListener()); 128 } 129 130 private final class SyncDialogWindowListener extends WindowAdapter { 131 private static final int CANCEL = -1; 132 private static final int DONE = 0; 133 private static final int AGAIN = 1; 134 private static final int NOTHING = 2; 135 136 private int checkAndSave() { 137 if (syncDialog.isVisible()) 138 // nothing happened: JOSM was minimized or similar 139 return NOTHING; 140 int answer = syncDialog.getValue(); 141 if (answer != 1) 142 return CANCEL; 143 144 // Parse values again, to display an error if the format is not recognized 145 try { 146 timezone = GpxTimezone.parseTimezone(tfTimezone.getText().trim()); 147 } catch (ParseException e) { 148 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), e.getMessage(), 149 tr("Invalid timezone"), JOptionPane.ERROR_MESSAGE); 150 return AGAIN; 151 } 152 153 try { 154 delta = GpxTimeOffset.parseOffset(tfOffset.getText().trim()); 155 } catch (ParseException e) { 156 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), e.getMessage(), 157 tr("Invalid offset"), JOptionPane.ERROR_MESSAGE); 158 return AGAIN; 159 } 160 161 if (lastNumMatched == 0 && new ExtendedDialog( 162 MainApplication.getMainFrame(), 163 tr("Correlate images with GPX track"), 164 tr("OK"), tr("Try Again")). 165 setContent(tr("No images could be matched!")). 166 setButtonIcons("ok", "dialogs/refresh"). 167 showDialog().getValue() == 2) 168 return AGAIN; 169 return DONE; 170 } 171 172 @Override 173 public void windowDeactivated(WindowEvent e) { 174 int result = checkAndSave(); 175 switch (result) { 176 case NOTHING: 177 break; 178 case CANCEL: 179 if (yLayer != null) { 180 for (ImageEntry ie : yLayer.getImageData().getImages()) { 181 ie.discardTmp(); 182 } 183 yLayer.updateBufferAndRepaint(); 184 } 185 break; 186 case AGAIN: 187 actionPerformed(null); 188 break; 189 case DONE: 190 Config.getPref().put("geoimage.timezone", timezone.formatTimezone()); 191 Config.getPref().put("geoimage.delta", delta.formatOffset()); 192 Config.getPref().putBoolean("geoimage.showThumbs", yLayer.useThumbs); 193 194 yLayer.useThumbs = cbShowThumbs.isSelected(); 195 yLayer.startLoadThumbs(); 196 197 // Search whether an other layer has yet defined some bounding box. 198 // If none, we'll zoom to the bounding box of the layer with the photos. 199 boolean boundingBoxedLayerFound = false; 200 for (Layer l: MainApplication.getLayerManager().getLayers()) { 201 if (l != yLayer) { 202 BoundingXYVisitor bbox = new BoundingXYVisitor(); 203 l.visitBoundingBox(bbox); 204 if (bbox.getBounds() != null) { 205 boundingBoxedLayerFound = true; 206 break; 207 } 208 } 209 } 210 if (!boundingBoxedLayerFound) { 211 BoundingXYVisitor bbox = new BoundingXYVisitor(); 212 yLayer.visitBoundingBox(bbox); 213 MainApplication.getMap().mapView.zoomTo(bbox); 214 } 215 216 for (ImageEntry ie : yLayer.getImageData().getImages()) { 217 ie.applyTmp(); 218 } 219 220 yLayer.updateBufferAndRepaint(); 221 222 break; 223 default: 224 throw new IllegalStateException(); 225 } 226 } 227 } 228 229 private static class GpxDataWrapper { 230 private final String name; 231 private final GpxData data; 232 private final File file; 233 234 GpxDataWrapper(String name, GpxData data, File file) { 235 this.name = name; 236 this.data = data; 237 this.file = file; 238 } 239 240 @Override 241 public String toString() { 242 return name; 243 } 244 } 245 246 private ExtendedDialog syncDialog; 247 private final transient List<GpxDataWrapper> gpxLst = new ArrayList<>(); 248 private JPanel outerPanel; 249 private JosmComboBox<GpxDataWrapper> cbGpx; 250 private JosmTextField tfTimezone; 251 private JosmTextField tfOffset; 252 private JCheckBox cbExifImg; 253 private JCheckBox cbTaggedImg; 254 private JCheckBox cbShowThumbs; 255 private JLabel statusBarText; 256 257 // remember the last number of matched photos 258 private int lastNumMatched; 259 260 /** This class is called when the user doesn't find the GPX file he needs in the files that have 261 * been loaded yet. It displays a FileChooser dialog to select the GPX file to be loaded. 262 */ 263 private class LoadGpxDataActionListener implements ActionListener { 264 265 @Override 266 public void actionPerformed(ActionEvent e) { 267 ExtensionFileFilter gpxFilter = GpxImporter.getFileFilter(); 268 AbstractFileChooser fc = new FileChooserManager(true, null).createFileChooser(false, null, 269 Arrays.asList(gpxFilter, NMEAImporter.FILE_FILTER), gpxFilter, JFileChooser.FILES_ONLY).openFileChooser(); 270 if (fc == null) 271 return; 272 File sel = fc.getSelectedFile(); 273 274 try { 275 outerPanel.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); 276 277 for (int i = gpxLst.size() - 1; i >= 0; i--) { 278 GpxDataWrapper wrapper = gpxLst.get(i); 279 if (sel.equals(wrapper.file)) { 280 cbGpx.setSelectedIndex(i); 281 if (!sel.getName().equals(wrapper.name)) { 282 JOptionPane.showMessageDialog( 283 MainApplication.getMainFrame(), 284 tr("File {0} is loaded yet under the name \"{1}\"", sel.getName(), wrapper.name), 285 tr("Error"), 286 JOptionPane.ERROR_MESSAGE 287 ); 288 } 289 return; 290 } 291 } 292 GpxData data = null; 293 try (InputStream iStream = Compression.getUncompressedFileInputStream(sel)) { 294 IGpxReader reader = gpxFilter.accept(sel) ? new GpxReader(iStream) : new NmeaReader(iStream); 295 reader.parse(false); 296 data = reader.getGpxData(); 297 data.storageFile = sel; 298 299 } catch (SAXException ex) { 300 Logging.error(ex); 301 JOptionPane.showMessageDialog( 302 MainApplication.getMainFrame(), 303 tr("Error while parsing {0}", sel.getName())+": "+ex.getMessage(), 304 tr("Error"), 305 JOptionPane.ERROR_MESSAGE 306 ); 307 return; 308 } catch (IOException ex) { 309 Logging.error(ex); 310 JOptionPane.showMessageDialog( 311 MainApplication.getMainFrame(), 312 tr("Could not read \"{0}\"", sel.getName())+'\n'+ex.getMessage(), 313 tr("Error"), 314 JOptionPane.ERROR_MESSAGE 315 ); 316 return; 317 } 318 319 MutableComboBoxModel<GpxDataWrapper> model = (MutableComboBoxModel<GpxDataWrapper>) cbGpx.getModel(); 320 loadedGpxData.add(data); 321 if (gpxLst.get(0).file == null) { 322 gpxLst.remove(0); 323 model.removeElementAt(0); 324 } 325 GpxDataWrapper elem = new GpxDataWrapper(sel.getName(), data, sel); 326 gpxLst.add(elem); 327 model.addElement(elem); 328 cbGpx.setSelectedIndex(cbGpx.getItemCount() - 1); 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 gpxLst.add(gdw); 796 MutableComboBoxModel<GpxDataWrapper> model = (MutableComboBoxModel<GpxDataWrapper>) cbGpx.getModel(); 797 if (gpxLst.get(0).file == null) { 798 gpxLst.remove(0); 799 model.removeElementAt(0); 800 } 801 model.addElement(gdw); 802 } 803 } 804 } 805 806 @Override 807 public void layerRemoving(LayerRemoveEvent e) { 808 // Not used 809 } 810 811 @Override 812 public void layerOrderChanged(LayerOrderChangeEvent e) { 813 // Not used 814 } 815 } 816 817 @Override 818 public void actionPerformed(ActionEvent ae) { 819 // Construct the list of loaded GPX tracks 820 gpxLst.clear(); 821 GpxDataWrapper defaultItem = null; 822 for (GpxLayer cur : MainApplication.getLayerManager().getLayersOfType(GpxLayer.class)) { 823 GpxDataWrapper gdw = new GpxDataWrapper(cur.getName(), cur.data, cur.data.storageFile); 824 gpxLst.add(gdw); 825 if (cur == yLayer.gpxLayer) { 826 defaultItem = gdw; 827 } 828 } 829 for (GpxData data : loadedGpxData) { 830 gpxLst.add(new GpxDataWrapper(data.storageFile.getName(), 831 data, 832 data.storageFile)); 833 } 834 835 if (gpxLst.isEmpty()) { 836 gpxLst.add(new GpxDataWrapper(tr("<No GPX track loaded yet>"), null, null)); 837 } 838 839 JPanel panelCb = new JPanel(); 840 841 panelCb.add(new JLabel(tr("GPX track: "))); 842 843 cbGpx = new JosmComboBox<>(gpxLst.toArray(new GpxDataWrapper[0])); 844 if (defaultItem != null) { 845 cbGpx.setSelectedItem(defaultItem); 846 } else { 847 // select first GPX track associated to a file 848 gpxLst.stream().filter(i -> i.file != null).findFirst().ifPresent(cbGpx::setSelectedItem); 849 } 850 cbGpx.addActionListener(statusBarUpdaterWithRepaint); 851 panelCb.add(cbGpx); 852 853 JButton buttonOpen = new JButton(tr("Open another GPX trace")); 854 buttonOpen.addActionListener(new LoadGpxDataActionListener()); 855 panelCb.add(buttonOpen); 856 857 JPanel panelTf = new JPanel(new GridBagLayout()); 858 859 try { 860 String tz = Config.getPref().get("geoimage.timezone"); 861 if (!tz.isEmpty()) { 862 timezone = GpxTimezone.parseTimezone(tz); 863 } else { 864 timezone = new GpxTimezone(TimeUnit.MILLISECONDS.toMinutes(TimeZone.getDefault().getRawOffset()) / 60.); //hours is double 865 } 866 } catch (ParseException e) { 867 timezone = GpxTimezone.ZERO; 868 Logging.trace(e); 869 } 870 871 tfTimezone = new JosmTextField(10); 872 tfTimezone.setText(timezone.formatTimezone()); 873 874 try { 875 delta = GpxTimeOffset.parseOffset(Config.getPref().get("geoimage.delta", "0")); 876 } catch (ParseException e) { 877 delta = GpxTimeOffset.ZERO; 878 Logging.trace(e); 879 } 880 881 tfOffset = new JosmTextField(10); 882 tfOffset.setText(delta.formatOffset()); 883 884 JButton buttonViewGpsPhoto = new JButton(tr("<html>Use photo of an accurate clock,<br>e.g. GPS receiver display</html>")); 885 buttonViewGpsPhoto.setIcon(ImageProvider.get("clock")); 886 buttonViewGpsPhoto.addActionListener(new SetOffsetActionListener()); 887 888 JButton buttonAutoGuess = new JButton(tr("Auto-Guess")); 889 buttonAutoGuess.setToolTipText(tr("Matches first photo with first gpx point")); 890 buttonAutoGuess.addActionListener(new AutoGuessActionListener()); 891 892 JButton buttonAdjust = new JButton(tr("Manual adjust")); 893 buttonAdjust.addActionListener(new AdjustActionListener()); 894 895 JButton buttonAdvanced = new JButton(tr("Advanced settings...")); 896 buttonAdvanced.addActionListener(new AdvancedSettingsActionListener()); 897 898 JLabel labelPosition = new JLabel(tr("Override position for: ")); 899 900 int numAll = getSortedImgList(true, true).size(); 901 int numExif = numAll - getSortedImgList(false, true).size(); 902 int numTagged = numAll - getSortedImgList(true, false).size(); 903 904 cbExifImg = new JCheckBox(tr("Images with geo location in exif data ({0}/{1})", numExif, numAll)); 905 cbExifImg.setEnabled(numExif != 0); 906 907 cbTaggedImg = new JCheckBox(tr("Images that are already tagged ({0}/{1})", numTagged, numAll), true); 908 cbTaggedImg.setEnabled(numTagged != 0); 909 910 labelPosition.setEnabled(cbExifImg.isEnabled() || cbTaggedImg.isEnabled()); 911 912 boolean ticked = yLayer.thumbsLoaded || Config.getPref().getBoolean("geoimage.showThumbs", false); 913 cbShowThumbs = new JCheckBox(tr("Show Thumbnail images on the map"), ticked); 914 cbShowThumbs.setEnabled(!yLayer.thumbsLoaded); 915 916 int y = 0; 917 GBC gbc = GBC.eol(); 918 gbc.gridx = 0; 919 gbc.gridy = y++; 920 panelTf.add(panelCb, gbc); 921 922 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 12); 923 gbc.gridx = 0; 924 gbc.gridy = y++; 925 panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc); 926 927 gbc = GBC.std(); 928 gbc.gridx = 0; 929 gbc.gridy = y; 930 panelTf.add(new JLabel(tr("Timezone: ")), gbc); 931 932 gbc = GBC.std().fill(GBC.HORIZONTAL); 933 gbc.gridx = 1; 934 gbc.gridy = y++; 935 gbc.weightx = 1.; 936 panelTf.add(tfTimezone, gbc); 937 938 gbc = GBC.std(); 939 gbc.gridx = 0; 940 gbc.gridy = y; 941 panelTf.add(new JLabel(tr("Offset:")), gbc); 942 943 gbc = GBC.std().fill(GBC.HORIZONTAL); 944 gbc.gridx = 1; 945 gbc.gridy = y++; 946 gbc.weightx = 1.; 947 panelTf.add(tfOffset, gbc); 948 949 gbc = GBC.std().insets(5, 5, 5, 5); 950 gbc.gridx = 2; 951 gbc.gridy = y-2; 952 gbc.gridheight = 2; 953 gbc.gridwidth = 2; 954 gbc.fill = GridBagConstraints.BOTH; 955 gbc.weightx = 0.5; 956 panelTf.add(buttonViewGpsPhoto, gbc); 957 958 gbc = GBC.std().fill(GBC.BOTH).insets(5, 5, 5, 5); 959 gbc.gridx = 1; 960 gbc.gridy = y++; 961 gbc.weightx = 0.5; 962 panelTf.add(buttonAdvanced, gbc); 963 964 gbc.gridx = 2; 965 panelTf.add(buttonAutoGuess, gbc); 966 967 gbc.gridx = 3; 968 panelTf.add(buttonAdjust, gbc); 969 970 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 12, 0, 0); 971 gbc.gridx = 0; 972 gbc.gridy = y++; 973 panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc); 974 975 gbc = GBC.eol(); 976 gbc.gridx = 0; 977 gbc.gridy = y++; 978 panelTf.add(labelPosition, gbc); 979 980 gbc = GBC.eol(); 981 gbc.gridx = 1; 982 gbc.gridy = y++; 983 panelTf.add(cbExifImg, gbc); 984 985 gbc = GBC.eol(); 986 gbc.gridx = 1; 987 gbc.gridy = y++; 988 panelTf.add(cbTaggedImg, gbc); 989 990 gbc = GBC.eol(); 991 gbc.gridx = 0; 992 gbc.gridy = y; 993 panelTf.add(cbShowThumbs, gbc); 994 995 final JPanel statusBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); 996 statusBar.setBorder(BorderFactory.createLoweredBevelBorder()); 997 statusBarText = new JLabel(" "); 998 statusBarText.setFont(statusBarText.getFont().deriveFont(8)); 999 statusBar.add(statusBarText); 1000 1001 tfTimezone.addFocusListener(repaintTheMap); 1002 tfOffset.addFocusListener(repaintTheMap); 1003 1004 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 1005 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 1006 cbExifImg.addItemListener(statusBarUpdaterWithRepaint); 1007 cbTaggedImg.addItemListener(statusBarUpdaterWithRepaint); 1008 1009 statusBarUpdater.updateStatusBar(); 1010 yLayer.updateBufferAndRepaint(); 1011 1012 outerPanel = new JPanel(new BorderLayout()); 1013 outerPanel.add(statusBar, BorderLayout.PAGE_END); 1014 1015 if (!GraphicsEnvironment.isHeadless()) { 1016 syncDialog = new ExtendedDialog( 1017 MainApplication.getMainFrame(), 1018 tr("Correlate images with GPX track"), 1019 new String[] {tr("Correlate"), tr("Cancel")}, 1020 false 1021 ); 1022 syncDialog.setContent(panelTf, false); 1023 syncDialog.setButtonIcons("ok", "cancel"); 1024 syncDialog.setupDialog(); 1025 outerPanel.add(syncDialog.getContentPane(), BorderLayout.PAGE_START); 1026 syncDialog.setContentPane(outerPanel); 1027 syncDialog.pack(); 1028 syncDialog.addWindowListener(new SyncDialogWindowListener()); 1029 syncDialog.showDialog(); 1030 } 1031 } 1032 1033 private final transient StatusBarUpdater statusBarUpdater = new StatusBarUpdater(false); 1034 private final transient StatusBarUpdater statusBarUpdaterWithRepaint = new StatusBarUpdater(true); 1035 1036 private class StatusBarUpdater implements DocumentListener, ItemListener, ActionListener { 1037 private final boolean doRepaint; 1038 1039 StatusBarUpdater(boolean doRepaint) { 1040 this.doRepaint = doRepaint; 1041 } 1042 1043 @Override 1044 public void insertUpdate(DocumentEvent ev) { 1045 updateStatusBar(); 1046 } 1047 1048 @Override 1049 public void removeUpdate(DocumentEvent ev) { 1050 updateStatusBar(); 1051 } 1052 1053 @Override 1054 public void changedUpdate(DocumentEvent ev) { 1055 // Do nothing 1056 } 1057 1058 @Override 1059 public void itemStateChanged(ItemEvent e) { 1060 updateStatusBar(); 1061 } 1062 1063 @Override 1064 public void actionPerformed(ActionEvent e) { 1065 updateStatusBar(); 1066 } 1067 1068 public void updateStatusBar() { 1069 statusBarText.setText(statusText()); 1070 if (doRepaint) { 1071 yLayer.updateBufferAndRepaint(); 1072 } 1073 } 1074 1075 private String statusText() { 1076 try { 1077 timezone = GpxTimezone.parseTimezone(tfTimezone.getText().trim()); 1078 delta = GpxTimeOffset.parseOffset(tfOffset.getText().trim()); 1079 } catch (ParseException e) { 1080 return e.getMessage(); 1081 } 1082 1083 // The selection of images we are about to correlate may have changed. 1084 // So reset all images. 1085 for (ImageEntry ie: yLayer.getImageData().getImages()) { 1086 ie.discardTmp(); 1087 } 1088 1089 // Construct a list of images that have a date, and sort them on the date. 1090 List<ImageEntry> dateImgLst = getSortedImgList(); 1091 // Create a temporary copy for each image 1092 for (ImageEntry ie : dateImgLst) { 1093 ie.createTmp(); 1094 ie.getTmp().setPos(null); 1095 } 1096 1097 GpxDataWrapper selGpx = selectedGPX(false); 1098 if (selGpx == null) 1099 return tr("No gpx selected"); 1100 1101 final long offsetMs = ((long) (timezone.getHours() * TimeUnit.HOURS.toMillis(1))) + delta.getMilliseconds(); // in milliseconds 1102 lastNumMatched = GpxImageCorrelation.matchGpxTrack(dateImgLst, selGpx.data, offsetMs, forceTags); 1103 1104 return trn("<html>Matched <b>{0}</b> of <b>{1}</b> photo to GPX track.</html>", 1105 "<html>Matched <b>{0}</b> of <b>{1}</b> photos to GPX track.</html>", 1106 dateImgLst.size(), lastNumMatched, dateImgLst.size()); 1107 } 1108 } 1109 1110 private final transient RepaintTheMapListener repaintTheMap = new RepaintTheMapListener(); 1111 1112 private class RepaintTheMapListener implements FocusListener { 1113 @Override 1114 public void focusGained(FocusEvent e) { // do nothing 1115 } 1116 1117 @Override 1118 public void focusLost(FocusEvent e) { 1119 yLayer.updateBufferAndRepaint(); 1120 } 1121 } 1122 1123 /** 1124 * Presents dialog with sliders for manual adjust. 1125 */ 1126 private class AdjustActionListener implements ActionListener { 1127 1128 @Override 1129 public void actionPerformed(ActionEvent arg0) { 1130 1131 final GpxTimeOffset offset = GpxTimeOffset.milliseconds( 1132 delta.getMilliseconds() + Math.round(timezone.getHours() * TimeUnit.HOURS.toMillis(1))); 1133 final int dayOffset = offset.getDayOffset(); 1134 final Pair<GpxTimezone, GpxTimeOffset> timezoneOffsetPair = offset.withoutDayOffset().splitOutTimezone(); 1135 1136 // Info Labels 1137 final JLabel lblMatches = new JLabel(); 1138 1139 // Timezone Slider 1140 // The slider allows to switch timezon from -12:00 to 12:00 in 30 minutes steps. Therefore the range is -24 to 24. 1141 final JLabel lblTimezone = new JLabel(); 1142 final JSlider sldTimezone = new JSlider(-24, 24, 0); 1143 sldTimezone.setPaintLabels(true); 1144 Dictionary<Integer, JLabel> labelTable = new Hashtable<>(); 1145 // CHECKSTYLE.OFF: ParenPad 1146 for (int i = -12; i <= 12; i += 6) { 1147 labelTable.put(i * 2, new JLabel(new GpxTimezone(i).formatTimezone())); 1148 } 1149 // CHECKSTYLE.ON: ParenPad 1150 sldTimezone.setLabelTable(labelTable); 1151 1152 // Minutes Slider 1153 final JLabel lblMinutes = new JLabel(); 1154 final JSlider sldMinutes = new JSlider(-15, 15, 0); 1155 sldMinutes.setPaintLabels(true); 1156 sldMinutes.setMajorTickSpacing(5); 1157 1158 // Seconds slider 1159 final JLabel lblSeconds = new JLabel(); 1160 final JSlider sldSeconds = new JSlider(-600, 600, 0); 1161 sldSeconds.setPaintLabels(true); 1162 labelTable = new Hashtable<>(); 1163 // CHECKSTYLE.OFF: ParenPad 1164 for (int i = -60; i <= 60; i += 30) { 1165 labelTable.put(i * 10, new JLabel(GpxTimeOffset.seconds(i).formatOffset())); 1166 } 1167 // CHECKSTYLE.ON: ParenPad 1168 sldSeconds.setLabelTable(labelTable); 1169 sldSeconds.setMajorTickSpacing(300); 1170 1171 // This is called whenever one of the sliders is moved. 1172 // It updates the labels and also calls the "match photos" code 1173 class SliderListener implements ChangeListener { 1174 @Override 1175 public void stateChanged(ChangeEvent e) { 1176 timezone = new GpxTimezone(sldTimezone.getValue() / 2.); 1177 1178 lblTimezone.setText(tr("Timezone: {0}", timezone.formatTimezone())); 1179 lblMinutes.setText(tr("Minutes: {0}", sldMinutes.getValue())); 1180 lblSeconds.setText(tr("Seconds: {0}", GpxTimeOffset.milliseconds(100L * sldSeconds.getValue()).formatOffset())); 1181 1182 delta = GpxTimeOffset.milliseconds(100L * sldSeconds.getValue() 1183 + TimeUnit.MINUTES.toMillis(sldMinutes.getValue()) 1184 + TimeUnit.DAYS.toMillis(dayOffset)); 1185 1186 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater); 1187 tfOffset.getDocument().removeDocumentListener(statusBarUpdater); 1188 1189 tfTimezone.setText(timezone.formatTimezone()); 1190 tfOffset.setText(delta.formatOffset()); 1191 1192 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 1193 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 1194 1195 lblMatches.setText(statusBarText.getText() + "<br>" + trn("(Time difference of {0} day)", 1196 "Time difference of {0} days", Math.abs(dayOffset), Math.abs(dayOffset))); 1197 1198 statusBarUpdater.updateStatusBar(); 1199 yLayer.updateBufferAndRepaint(); 1200 } 1201 } 1202 1203 // Put everything together 1204 JPanel p = new JPanel(new GridBagLayout()); 1205 p.setPreferredSize(new Dimension(400, 230)); 1206 p.add(lblMatches, GBC.eol().fill()); 1207 p.add(lblTimezone, GBC.eol().fill()); 1208 p.add(sldTimezone, GBC.eol().fill().insets(0, 0, 0, 10)); 1209 p.add(lblMinutes, GBC.eol().fill()); 1210 p.add(sldMinutes, GBC.eol().fill().insets(0, 0, 0, 10)); 1211 p.add(lblSeconds, GBC.eol().fill()); 1212 p.add(sldSeconds, GBC.eol().fill()); 1213 1214 // If there's an error in the calculation the found values 1215 // will be off range for the sliders. Catch this error 1216 // and inform the user about it. 1217 try { 1218 sldTimezone.setValue((int) (timezoneOffsetPair.a.getHours() * 2)); 1219 sldMinutes.setValue((int) (timezoneOffsetPair.b.getSeconds() / 60)); 1220 final long deciSeconds = timezoneOffsetPair.b.getMilliseconds() / 100; 1221 sldSeconds.setValue((int) (deciSeconds % 600)); 1222 } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException e) { 1223 Logging.warn(e); 1224 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), 1225 tr("An error occurred while trying to match the photos to the GPX track." 1226 +" You can adjust the sliders to manually match the photos."), 1227 tr("Matching photos to track failed"), 1228 JOptionPane.WARNING_MESSAGE); 1229 } 1230 1231 // Call the sliderListener once manually so labels get adjusted 1232 new SliderListener().stateChanged(null); 1233 // Listeners added here, otherwise it tries to match three times 1234 // (when setting the default values) 1235 sldTimezone.addChangeListener(new SliderListener()); 1236 sldMinutes.addChangeListener(new SliderListener()); 1237 sldSeconds.addChangeListener(new SliderListener()); 1238 1239 // There is no way to cancel this dialog, all changes get applied 1240 // immediately. Therefore "Close" is marked with an "OK" icon. 1241 // Settings are only saved temporarily to the layer. 1242 new ExtendedDialog(MainApplication.getMainFrame(), 1243 tr("Adjust timezone and offset"), 1244 tr("Close")). 1245 setContent(p).setButtonIcons("ok").showDialog(); 1246 } 1247 } 1248 1249 static class NoGpxTimestamps extends Exception { 1250 } 1251 1252 /** 1253 * Tries to auto-guess the timezone and offset. 1254 * 1255 * @param imgs the images to correlate 1256 * @param gpx the gpx track to correlate to 1257 * @return a pair of timezone and offset 1258 * @throws IndexOutOfBoundsException when there are no images 1259 * @throws NoGpxTimestamps when the gpx track does not contain a timestamp 1260 */ 1261 static Pair<GpxTimezone, GpxTimeOffset> autoGuess(List<ImageEntry> imgs, GpxData gpx) throws NoGpxTimestamps { 1262 1263 // Init variables 1264 long firstExifDate = imgs.get(0).getExifTime().getTime(); 1265 1266 long firstGPXDate = -1; 1267 // Finds first GPX point 1268 outer: for (GpxTrack trk : gpx.tracks) { 1269 for (GpxTrackSegment segment : trk.getSegments()) { 1270 for (WayPoint curWp : segment.getWayPoints()) { 1271 if (curWp.hasDate()) { 1272 firstGPXDate = curWp.getTimeInMillis(); 1273 break outer; 1274 } 1275 } 1276 } 1277 } 1278 1279 if (firstGPXDate < 0) { 1280 throw new NoGpxTimestamps(); 1281 } 1282 1283 return GpxTimeOffset.milliseconds(firstExifDate - firstGPXDate).splitOutTimezone(); 1284 } 1285 1286 private class AutoGuessActionListener implements ActionListener { 1287 1288 @Override 1289 public void actionPerformed(ActionEvent arg0) { 1290 GpxDataWrapper gpxW = selectedGPX(true); 1291 if (gpxW == null) 1292 return; 1293 GpxData gpx = gpxW.data; 1294 1295 List<ImageEntry> imgs = getSortedImgList(); 1296 1297 try { 1298 final Pair<GpxTimezone, GpxTimeOffset> r = autoGuess(imgs, gpx); 1299 timezone = r.a; 1300 delta = r.b; 1301 } catch (IndexOutOfBoundsException ex) { 1302 Logging.debug(ex); 1303 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), 1304 tr("The selected photos do not contain time information."), 1305 tr("Photos do not contain time information"), JOptionPane.WARNING_MESSAGE); 1306 return; 1307 } catch (NoGpxTimestamps ex) { 1308 Logging.debug(ex); 1309 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), 1310 tr("The selected GPX track does not contain timestamps. Please select another one."), 1311 tr("GPX Track has no time information"), JOptionPane.WARNING_MESSAGE); 1312 return; 1313 } 1314 1315 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater); 1316 tfOffset.getDocument().removeDocumentListener(statusBarUpdater); 1317 1318 tfTimezone.setText(timezone.formatTimezone()); 1319 tfOffset.setText(delta.formatOffset()); 1320 tfOffset.requestFocus(); 1321 1322 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 1323 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 1324 1325 statusBarUpdater.updateStatusBar(); 1326 yLayer.updateBufferAndRepaint(); 1327 } 1328 } 1329 1330 private List<ImageEntry> getSortedImgList() { 1331 return getSortedImgList(cbExifImg.isSelected(), cbTaggedImg.isSelected()); 1332 } 1333 1334 /** 1335 * Returns a list of images that fulfill the given criteria. 1336 * Default setting is to return untagged images, but may be overwritten. 1337 * @param exif also returns images with exif-gps info 1338 * @param tagged also returns tagged images 1339 * @return matching images 1340 */ 1341 private List<ImageEntry> getSortedImgList(boolean exif, boolean tagged) { 1342 List<ImageEntry> dateImgLst = new ArrayList<>(yLayer.getImageData().getImages().size()); 1343 for (ImageEntry e : yLayer.getImageData().getImages()) { 1344 if (!e.hasExifTime()) { 1345 continue; 1346 } 1347 1348 if (e.getExifCoor() != null && !exif) { 1349 continue; 1350 } 1351 1352 if (!tagged && e.isTagged() && e.getExifCoor() == null) { 1353 continue; 1354 } 1355 1356 dateImgLst.add(e); 1357 } 1358 1359 dateImgLst.sort(Comparator.comparing(ImageEntry::getExifTime)); 1360 1361 return dateImgLst; 1362 } 1363 1364 private GpxDataWrapper selectedGPX(boolean complain) { 1365 Object item = cbGpx.getSelectedItem(); 1366 1367 if (item == null || ((GpxDataWrapper) item).file == null) { 1368 if (complain) { 1369 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), tr("You should select a GPX track"), 1370 tr("No selected GPX track"), JOptionPane.ERROR_MESSAGE); 1371 } 1372 return null; 1373 } 1374 return (GpxDataWrapper) item; 1375 } 1376 1377}