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