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.Cursor; 009import java.awt.FlowLayout; 010import java.awt.Font; 011import java.awt.GraphicsEnvironment; 012import java.awt.GridBagConstraints; 013import java.awt.GridBagLayout; 014import java.awt.event.ActionEvent; 015import java.awt.event.ActionListener; 016import java.awt.event.FocusEvent; 017import java.awt.event.FocusListener; 018import java.awt.event.ItemEvent; 019import java.awt.event.ItemListener; 020import java.awt.event.WindowAdapter; 021import java.awt.event.WindowEvent; 022import java.beans.PropertyChangeEvent; 023import java.beans.PropertyChangeListener; 024import java.io.File; 025import java.text.ParseException; 026import java.util.List; 027import java.util.Objects; 028import java.util.Optional; 029import java.util.TimeZone; 030import java.util.concurrent.TimeUnit; 031import java.util.function.Consumer; 032 033import javax.swing.AbstractAction; 034import javax.swing.BorderFactory; 035import javax.swing.DefaultComboBoxModel; 036import javax.swing.JButton; 037import javax.swing.JCheckBox; 038import javax.swing.JLabel; 039import javax.swing.JOptionPane; 040import javax.swing.JPanel; 041import javax.swing.JSeparator; 042import javax.swing.SwingConstants; 043import javax.swing.event.ChangeEvent; 044import javax.swing.event.ChangeListener; 045import javax.swing.event.DocumentEvent; 046import javax.swing.event.DocumentListener; 047 048import org.openstreetmap.josm.actions.ExpertToggleAction; 049import org.openstreetmap.josm.actions.ExpertToggleAction.ExpertModeChangeListener; 050import org.openstreetmap.josm.data.gpx.GpxData; 051import org.openstreetmap.josm.data.gpx.GpxData.GpxDataChangeEvent; 052import org.openstreetmap.josm.data.gpx.GpxData.GpxDataChangeListener; 053import org.openstreetmap.josm.data.gpx.GpxDataContainer; 054import org.openstreetmap.josm.data.gpx.GpxImageCorrelation; 055import org.openstreetmap.josm.data.gpx.GpxImageCorrelationSettings; 056import org.openstreetmap.josm.data.gpx.GpxTimeOffset; 057import org.openstreetmap.josm.data.gpx.GpxTimezone; 058import org.openstreetmap.josm.data.gpx.WayPoint; 059import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 060import org.openstreetmap.josm.gui.ExtendedDialog; 061import org.openstreetmap.josm.gui.MainApplication; 062import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer; 063import org.openstreetmap.josm.gui.layer.Layer; 064import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent; 065import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener; 066import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent; 067import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent; 068import org.openstreetmap.josm.gui.layer.geoimage.AdjustTimezoneAndOffsetDialog.AdjustListener; 069import org.openstreetmap.josm.gui.layer.geoimage.SynchronizeTimeFromPhotoDialog.TimeZoneItem; 070import org.openstreetmap.josm.gui.layer.gpx.GpxDataHelper; 071import org.openstreetmap.josm.gui.widgets.JosmComboBox; 072import org.openstreetmap.josm.gui.widgets.JosmComboBoxModel; 073import org.openstreetmap.josm.gui.widgets.JosmTextField; 074import org.openstreetmap.josm.spi.preferences.Config; 075import org.openstreetmap.josm.tools.Destroyable; 076import org.openstreetmap.josm.tools.GBC; 077import org.openstreetmap.josm.tools.ImageProvider; 078import org.openstreetmap.josm.tools.Logging; 079import org.openstreetmap.josm.tools.Pair; 080 081/** 082 * This class displays the window to select the GPX file and the offset (timezone + delta). 083 * Then it correlates the images of the layer with that GPX file. 084 * @since 2566 085 */ 086public class CorrelateGpxWithImages extends AbstractAction implements ExpertModeChangeListener, Destroyable { 087 088 private static JosmComboBoxModel<GpxDataWrapper> gpxModel; 089 private static boolean forceTags; 090 091 private final transient GeoImageLayer yLayer; 092 private transient CorrelationSupportLayer supportLayer; 093 private transient GpxTimezone timezone; 094 private transient GpxTimeOffset delta; 095 096 /** 097 * Constructs a new {@code CorrelateGpxWithImages} action. 098 * @param layer The image layer 099 */ 100 public CorrelateGpxWithImages(GeoImageLayer layer) { 101 super(tr("Correlate to GPX")); 102 new ImageProvider("dialogs/geoimage/gpx2img").getResource().attachImageIcon(this, true); 103 this.yLayer = layer; 104 ExpertToggleAction.addExpertModeChangeListener(this); 105 } 106 107 private final class SyncDialogWindowListener extends WindowAdapter { 108 private static final int CANCEL = -1; 109 private static final int DONE = 0; 110 private static final int AGAIN = 1; 111 private static final int NOTHING = 2; 112 113 private int checkAndSave() { 114 if (syncDialog.isVisible()) 115 // nothing happened: JOSM was minimized or similar 116 return NOTHING; 117 int answer = syncDialog.getValue(); 118 if (answer != 1) 119 return CANCEL; 120 121 // Parse values again, to display an error if the format is not recognized 122 try { 123 timezone = GpxTimezone.parseTimezone(tfTimezone.getText().trim()); 124 } catch (ParseException e) { 125 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), e.getMessage(), 126 tr("Invalid timezone"), JOptionPane.ERROR_MESSAGE); 127 return AGAIN; 128 } 129 130 try { 131 delta = GpxTimeOffset.parseOffset(tfOffset.getText().trim()); 132 } catch (ParseException e) { 133 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), e.getMessage(), 134 tr("Invalid offset"), JOptionPane.ERROR_MESSAGE); 135 return AGAIN; 136 } 137 138 if (lastNumMatched == 0 && new ExtendedDialog( 139 MainApplication.getMainFrame(), 140 tr("Correlate images with GPX track"), 141 tr("OK"), tr("Try Again")). 142 setContent(tr("No images could be matched!")). 143 setButtonIcons("ok", "dialogs/refresh"). 144 showDialog().getValue() == 2) 145 return AGAIN; 146 return DONE; 147 } 148 149 @Override 150 public void windowDeactivated(WindowEvent e) { 151 int result = checkAndSave(); 152 switch (result) { 153 case NOTHING: 154 break; 155 case CANCEL: 156 if (yLayer != null) { 157 yLayer.discardTmp(); 158 yLayer.updateBufferAndRepaint(); 159 } 160 removeSupportLayer(); 161 break; 162 case AGAIN: 163 actionPerformed(null); 164 break; 165 case DONE: 166 Config.getPref().put("geoimage.timezone", timezone.formatTimezone()); 167 Config.getPref().put("geoimage.delta", delta.formatOffset()); 168 Config.getPref().putBoolean("geoimage.showThumbs", yLayer.useThumbs); 169 170 yLayer.useThumbs = cbShowThumbs.isSelected(); 171 yLayer.startLoadThumbs(); 172 173 // Search whether an other layer has yet defined some bounding box. 174 // If none, we'll zoom to the bounding box of the layer with the photos. 175 boolean boundingBoxedLayerFound = false; 176 for (Layer l: MainApplication.getLayerManager().getLayers()) { 177 if (l != yLayer) { 178 BoundingXYVisitor bbox = new BoundingXYVisitor(); 179 l.visitBoundingBox(bbox); 180 if (bbox.getBounds() != null) { 181 boundingBoxedLayerFound = true; 182 break; 183 } 184 } 185 } 186 if (!boundingBoxedLayerFound) { 187 BoundingXYVisitor bbox = new BoundingXYVisitor(); 188 yLayer.visitBoundingBox(bbox); 189 MainApplication.getMap().mapView.zoomTo(bbox); 190 } 191 192 yLayer.applyTmp(); 193 yLayer.updateBufferAndRepaint(); 194 removeSupportLayer(); 195 196 break; 197 default: 198 throw new IllegalStateException(Integer.toString(result)); 199 } 200 } 201 } 202 203 private void removeSupportLayer() { 204 if (supportLayer != null) { 205 MainApplication.getLayerManager().removeLayer(supportLayer); 206 supportLayer = null; 207 } 208 } 209 210 private static class GpxDataWrapper { 211 private String name; 212 private final GpxData data; 213 private final File file; 214 215 GpxDataWrapper(String name, GpxData data, File file) { 216 this.name = name; 217 this.data = data; 218 this.file = file; 219 } 220 221 void setName(String name) { 222 this.name = name; 223 forEachLayer(CorrelateGpxWithImages::repaintCombobox); 224 } 225 226 @Override 227 public String toString() { 228 return name; 229 } 230 } 231 232 private static class NoGpxDataWrapper extends GpxDataWrapper { 233 NoGpxDataWrapper() { 234 super(null, null, null); 235 } 236 237 @Override 238 public String toString() { 239 return tr("<No GPX track loaded yet>"); 240 } 241 } 242 243 private ExtendedDialog syncDialog; 244 private JPanel outerPanel; 245 private JosmComboBox<GpxDataWrapper> cbGpx; 246 private JButton buttonSupport; 247 private JosmTextField tfTimezone; 248 private JosmTextField tfOffset; 249 private JCheckBox cbExifImg; 250 private JCheckBox cbTaggedImg; 251 private JCheckBox cbShowThumbs; 252 private JLabel statusBarText; 253 private JSeparator sepDirectionPosition; 254 private ImageDirectionPositionPanel pDirectionPosition; 255 256 // remember the last number of matched photos 257 private int lastNumMatched; 258 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 File sel = GpxDataHelper.chooseGpxDataFile(); 268 if (sel != null) { 269 try { 270 outerPanel.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); 271 removeDuplicates(sel); 272 GpxData data = GpxDataHelper.loadGpxData(sel); 273 if (data != null) { 274 GpxDataWrapper elem = new GpxDataWrapper(sel.getName(), data, sel); 275 gpxModel.addElement(elem); 276 gpxModel.setSelectedItem(elem); 277 statusBarUpdater.matchAndUpdateStatusBar(); 278 } 279 } finally { 280 outerPanel.setCursor(Cursor.getDefaultCursor()); 281 } 282 } 283 } 284 } 285 286 private class UseSupportLayerActionListener implements ActionListener { 287 288 @Override 289 public void actionPerformed(ActionEvent e) { 290 Optional.ofNullable(selectedGPX(true)).ifPresent(gpx -> { 291 supportLayer = new CorrelationSupportLayer(gpx.data); 292 supportLayer.getGpxData().addChangeListener(statusBarUpdaterWithRepaint); 293 MainApplication.getLayerManager().addLayer(supportLayer); 294 }); 295 } 296 } 297 298 private class AdvancedSettingsActionListener implements ActionListener { 299 300 @Override 301 public void actionPerformed(ActionEvent e) { 302 AdvancedCorrelationSettingsDialog ed = new AdvancedCorrelationSettingsDialog(MainApplication.getMainFrame(), forceTags); 303 if (ed.showDialog().getValue() == 1) { 304 forceTags = ed.isForceTaggingSelected(); // This setting is not supposed to be saved permanently 305 306 statusBarUpdater.matchAndUpdateStatusBar(); 307 yLayer.updateBufferAndRepaint(); 308 } 309 } 310 } 311 312 /** 313 * This action listener is called when the user has a photo of the time of his GPS receiver. It 314 * displays the list of photos of the layer, and upon selection displays the selected photo. 315 * From that photo, the user can key in the time of the GPS. 316 * Then values of timezone and delta are set. 317 * @author chris 318 */ 319 private class SetOffsetActionListener implements ActionListener { 320 321 @Override 322 public void actionPerformed(ActionEvent e) { 323 boolean isOk = false; 324 while (!isOk) { 325 SynchronizeTimeFromPhotoDialog ed = new SynchronizeTimeFromPhotoDialog( 326 MainApplication.getMainFrame(), yLayer.getImageData().getImages()); 327 int answer = ed.showDialog().getValue(); 328 if (answer != 1) 329 return; 330 331 long delta; 332 333 try { 334 delta = ed.getDelta(); 335 } catch (ParseException ex) { 336 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), tr("Error while parsing the date.\n" 337 + "Please use the requested format"), 338 tr("Invalid date"), JOptionPane.ERROR_MESSAGE); 339 continue; 340 } 341 342 TimeZoneItem selectedTz = ed.getTimeZoneItem(); 343 344 Config.getPref().put("geoimage.timezoneid", selectedTz.getID()); 345 Config.getPref().putBoolean("geoimage.timezoneid.dst", ed.isDstSelected()); 346 tfOffset.setText(GpxTimeOffset.milliseconds(delta).formatOffset()); 347 tfTimezone.setText(selectedTz.getFormattedString()); 348 349 isOk = true; 350 } 351 statusBarUpdater.matchAndUpdateStatusBar(); 352 yLayer.updateBufferAndRepaint(); 353 } 354 } 355 356 private static class GpxLayerAddedListener implements LayerChangeListener { 357 @Override 358 public void layerAdded(LayerAddEvent e) { 359 Layer layer = e.getAddedLayer(); 360 if (layer instanceof GpxDataContainer) { 361 GpxData gpx = ((GpxDataContainer) layer).getGpxData(); 362 File file = gpx.storageFile; 363 removeDuplicates(file); 364 GpxDataWrapper gdw = new GpxDataWrapper(layer.getName(), gpx, file); 365 layer.addPropertyChangeListener(new GpxLayerRenamedListener(gdw)); 366 gpxModel.addElement(gdw); 367 forEachLayer(correlateAction -> { 368 correlateAction.repaintCombobox(); 369 if (layer.equals(correlateAction.supportLayer)) { 370 correlateAction.buttonSupport.setEnabled(false); 371 } 372 }); 373 } 374 } 375 376 @Override 377 public void layerRemoving(LayerRemoveEvent e) { 378 Layer layer = e.getRemovedLayer(); 379 if (layer instanceof GpxDataContainer) { 380 GpxData removedGpxData = ((GpxDataContainer) layer).getGpxData(); 381 for (int i = gpxModel.getSize() - 1; i >= 0; i--) { 382 GpxData data = gpxModel.getElementAt(i).data; 383 // removedGpxData can be null if gpx layer has been destroyed before this listener 384 if (data.equals(removedGpxData) || (removedGpxData == null && data.isEmpty())) { 385 gpxModel.removeElementAt(i); 386 forEachLayer(correlateAction -> { 387 correlateAction.repaintCombobox(); 388 if (layer.equals(correlateAction.supportLayer)) { 389 correlateAction.supportLayer.getGpxData() 390 .removeChangeListener(correlateAction.statusBarUpdaterWithRepaint); 391 correlateAction.supportLayer = null; 392 correlateAction.buttonSupport.setEnabled(true); 393 } 394 }); 395 break; 396 } 397 } 398 } 399 } 400 401 @Override 402 public void layerOrderChanged(LayerOrderChangeEvent e) { 403 // Not used 404 } 405 } 406 407 private static class GpxLayerRenamedListener implements PropertyChangeListener { 408 private final GpxDataWrapper gdw; 409 GpxLayerRenamedListener(GpxDataWrapper gdw) { 410 this.gdw = gdw; 411 } 412 413 @Override 414 public void propertyChange(PropertyChangeEvent e) { 415 if (Layer.NAME_PROP.equals(e.getPropertyName())) { 416 gdw.setName(e.getNewValue().toString()); 417 } 418 } 419 } 420 421 /** 422 * Construct the list of loaded GPX tracks 423 * @param nogdw Data wrapper with no GPX data 424 */ 425 private void constructGpxModel(NoGpxDataWrapper nogdw) { 426 gpxModel = new JosmComboBoxModel<>(); 427 GpxDataWrapper defaultItem = null; 428 for (AbstractModifiableLayer cur : MainApplication.getLayerManager().getLayersOfType(AbstractModifiableLayer.class)) { 429 if (cur instanceof GpxDataContainer) { 430 GpxData data = ((GpxDataContainer) cur).getGpxData(); 431 GpxDataWrapper gdw = new GpxDataWrapper(cur.getName(), data, data.storageFile); 432 cur.addPropertyChangeListener(new GpxLayerRenamedListener(gdw)); 433 gpxModel.addElement(gdw); 434 if (data.equals(yLayer.gpxData) || defaultItem == null) { 435 defaultItem = gdw; 436 } 437 } 438 } 439 440 if (gpxModel.getSize() == 0) { 441 gpxModel.addElement(nogdw); 442 } else if (defaultItem != null) { 443 gpxModel.setSelectedItem(defaultItem); 444 } 445 MainApplication.getLayerManager().addLayerChangeListener(new GpxLayerAddedListener()); 446 } 447 448 static GpxTimezone loadTimezone() { 449 try { 450 String tz = Config.getPref().get("geoimage.timezone"); 451 if (!tz.isEmpty()) { 452 return GpxTimezone.parseTimezone(tz); 453 } else { 454 return new GpxTimezone(TimeUnit.MILLISECONDS.toMinutes(TimeZone.getDefault().getRawOffset()) / 60.); //hours is double 455 } 456 } catch (ParseException e) { 457 Logging.trace(e); 458 return GpxTimezone.ZERO; 459 } 460 } 461 462 static GpxTimeOffset loadDelta() { 463 try { 464 return GpxTimeOffset.parseOffset(Config.getPref().get("geoimage.delta", "0")); 465 } catch (ParseException e) { 466 Logging.trace(e); 467 return GpxTimeOffset.ZERO; 468 } 469 } 470 471 @Override 472 public void actionPerformed(ActionEvent ae) { 473 NoGpxDataWrapper nogdw = new NoGpxDataWrapper(); 474 if (gpxModel == null) { 475 constructGpxModel(nogdw); 476 } 477 478 JPanel panelCb = new JPanel(); 479 480 panelCb.add(new JLabel(tr("GPX track: "))); 481 482 cbGpx = new JosmComboBox<>(gpxModel); 483 cbGpx.setPrototypeDisplayValue(nogdw); 484 cbGpx.addActionListener(statusBarUpdaterWithRepaint); 485 panelCb.add(cbGpx); 486 487 JButton buttonOpen = new JButton(tr("Open another GPX trace")); 488 buttonOpen.addActionListener(new LoadGpxDataActionListener()); 489 panelCb.add(buttonOpen); 490 491 buttonSupport = new JButton(tr("Use support layer")); 492 buttonSupport.addActionListener(new UseSupportLayerActionListener()); 493 panelCb.add(buttonSupport); 494 495 JPanel panelTf = new JPanel(new GridBagLayout()); 496 497 timezone = loadTimezone(); 498 499 tfTimezone = new JosmTextField(10); 500 tfTimezone.setText(timezone.formatTimezone()); 501 502 delta = loadDelta(); 503 504 tfOffset = new JosmTextField(10); 505 tfOffset.setText(delta.formatOffset()); 506 507 JButton buttonViewGpsPhoto = new JButton(tr("<html>Use photo of an accurate clock,<br>e.g. GPS receiver display</html>")); 508 buttonViewGpsPhoto.setIcon(ImageProvider.get("clock")); 509 buttonViewGpsPhoto.addActionListener(new SetOffsetActionListener()); 510 511 JButton buttonAutoGuess = new JButton(tr("Auto-Guess")); 512 buttonAutoGuess.setToolTipText(tr("Matches first photo with first gpx point")); 513 buttonAutoGuess.addActionListener(new AutoGuessActionListener()); 514 515 JButton buttonAdjust = new JButton(tr("Manual adjust")); 516 buttonAdjust.addActionListener(new AdjustActionListener()); 517 518 JButton buttonAdvanced = new JButton(tr("Advanced settings...")); 519 buttonAdvanced.addActionListener(new AdvancedSettingsActionListener()); 520 521 JLabel labelPosition = new JLabel(tr("Override position for: ")); 522 523 int numAll = yLayer.getSortedImgList(true, true).size(); 524 int numExif = numAll - yLayer.getSortedImgList(false, true).size(); 525 int numTagged = numAll - yLayer.getSortedImgList(true, false).size(); 526 527 cbExifImg = new JCheckBox(tr("Images with geo location in exif data ({0}/{1})", numExif, numAll)); 528 cbExifImg.setEnabled(numExif != 0); 529 530 cbTaggedImg = new JCheckBox(tr("Images that are already tagged ({0}/{1})", numTagged, numAll), true); 531 cbTaggedImg.setEnabled(numTagged != 0); 532 533 labelPosition.setEnabled(cbExifImg.isEnabled() || cbTaggedImg.isEnabled()); 534 535 boolean ticked = yLayer.thumbsLoaded || Config.getPref().getBoolean("geoimage.showThumbs", false); 536 cbShowThumbs = new JCheckBox(tr("Show Thumbnail images on the map"), ticked); 537 cbShowThumbs.setEnabled(!yLayer.thumbsLoaded); 538 539 int y = 0; 540 GBC gbc = GBC.eol(); 541 gbc.gridx = 0; 542 gbc.gridy = y++; 543 panelTf.add(panelCb, gbc); 544 545 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 12); 546 gbc.gridx = 0; 547 gbc.gridy = y++; 548 panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc); 549 550 gbc = GBC.std(); 551 gbc.gridx = 0; 552 gbc.gridy = y; 553 panelTf.add(new JLabel(tr("Timezone: ")), gbc); 554 555 gbc = GBC.std().fill(GBC.HORIZONTAL); 556 gbc.gridx = 1; 557 gbc.gridy = y++; 558 gbc.weightx = 1.; 559 panelTf.add(tfTimezone, gbc); 560 561 gbc = GBC.std(); 562 gbc.gridx = 0; 563 gbc.gridy = y; 564 panelTf.add(new JLabel(tr("Offset:")), gbc); 565 566 gbc = GBC.std().fill(GBC.HORIZONTAL); 567 gbc.gridx = 1; 568 gbc.gridy = y++; 569 gbc.weightx = 1.; 570 panelTf.add(tfOffset, gbc); 571 572 gbc = GBC.std().insets(5, 5, 5, 5); 573 gbc.gridx = 2; 574 gbc.gridy = y-2; 575 gbc.gridheight = 2; 576 gbc.gridwidth = 2; 577 gbc.fill = GridBagConstraints.BOTH; 578 gbc.weightx = 0.5; 579 panelTf.add(buttonViewGpsPhoto, gbc); 580 581 gbc = GBC.std().fill(GBC.BOTH).insets(5, 5, 5, 5); 582 gbc.gridx = 1; 583 gbc.gridy = y++; 584 gbc.weightx = 0.5; 585 panelTf.add(buttonAdvanced, gbc); 586 587 gbc.gridx = 2; 588 panelTf.add(buttonAutoGuess, gbc); 589 590 gbc.gridx = 3; 591 panelTf.add(buttonAdjust, gbc); 592 593 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 12, 0, 0); 594 gbc.gridx = 0; 595 gbc.gridy = y++; 596 panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc); 597 598 gbc = GBC.eol(); 599 gbc.gridx = 0; 600 gbc.gridy = y++; 601 panelTf.add(labelPosition, gbc); 602 603 gbc = GBC.eol(); 604 gbc.gridx = 1; 605 gbc.gridy = y++; 606 panelTf.add(cbExifImg, gbc); 607 608 gbc = GBC.eol(); 609 gbc.gridx = 1; 610 gbc.gridy = y++; 611 panelTf.add(cbTaggedImg, gbc); 612 613 gbc = GBC.eol(); 614 gbc.gridx = 0; 615 gbc.gridy = y; 616 panelTf.add(cbShowThumbs, gbc); 617 618 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 12, 0, 0); 619 sepDirectionPosition = new JSeparator(SwingConstants.HORIZONTAL); 620 panelTf.add(sepDirectionPosition, gbc); 621 622 gbc = GBC.eol(); 623 gbc.gridwidth = 3; 624 pDirectionPosition = ImageDirectionPositionPanel.forGpxTrace(); 625 panelTf.add(pDirectionPosition, gbc); 626 627 expertChanged(ExpertToggleAction.isExpert()); 628 629 final JPanel statusBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); 630 statusBar.setBorder(BorderFactory.createLoweredBevelBorder()); 631 statusBarText = new JLabel(" "); 632 statusBarText.setFont(statusBarText.getFont().deriveFont(Font.PLAIN, 8)); 633 statusBar.add(statusBarText); 634 635 RepaintTheMapListener repaintTheMap = new RepaintTheMapListener(yLayer); 636 pDirectionPosition.addFocusListenerOnComponent(repaintTheMap); 637 tfTimezone.addFocusListener(repaintTheMap); 638 tfOffset.addFocusListener(repaintTheMap); 639 640 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 641 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 642 cbExifImg.addItemListener(statusBarUpdaterWithRepaint); 643 cbTaggedImg.addItemListener(statusBarUpdaterWithRepaint); 644 pDirectionPosition.addChangeListenerOnComponents(statusBarUpdaterWithRepaint); 645 pDirectionPosition.addItemListenerOnComponents(statusBarUpdaterWithRepaint); 646 647 outerPanel = new JPanel(new BorderLayout()); 648 outerPanel.add(statusBar, BorderLayout.PAGE_END); 649 650 if (!GraphicsEnvironment.isHeadless()) { 651 forEachLayer(CorrelateGpxWithImages::closeDialog); 652 syncDialog = new ExtendedDialog( 653 MainApplication.getMainFrame(), 654 tr("Correlate images with GPX track"), 655 new String[] {tr("Correlate"), tr("Cancel")}, 656 false 657 ); 658 syncDialog.setContent(panelTf, false); 659 syncDialog.setButtonIcons("ok", "cancel"); 660 syncDialog.setupDialog(); 661 outerPanel.add(syncDialog.getContentPane(), BorderLayout.PAGE_START); 662 syncDialog.setContentPane(outerPanel); 663 syncDialog.pack(); 664 syncDialog.addWindowListener(new SyncDialogWindowListener()); 665 syncDialog.showDialog(); 666 667 statusBarUpdater.matchAndUpdateStatusBar(); 668 yLayer.updateBufferAndRepaint(); 669 } 670 } 671 672 @Override 673 public void expertChanged(boolean isExpert) { 674 if (buttonSupport != null) { 675 buttonSupport.setVisible(isExpert); 676 } 677 if (sepDirectionPosition != null) { 678 sepDirectionPosition.setVisible(isExpert); 679 } 680 if (pDirectionPosition != null) { 681 pDirectionPosition.setVisible(isExpert); 682 } 683 if (syncDialog != null) { 684 syncDialog.pack(); 685 } 686 } 687 688 private static void removeDuplicates(File file) { 689 for (int i = gpxModel.getSize() - 1; i >= 0; i--) { 690 GpxDataWrapper wrapper = gpxModel.getElementAt(i); 691 if (wrapper instanceof NoGpxDataWrapper || (file != null && file.equals(wrapper.file))) { 692 gpxModel.removeElement(wrapper); 693 } 694 } 695 } 696 697 private static void forEachLayer(Consumer<CorrelateGpxWithImages> action) { 698 MainApplication.getLayerManager().getLayersOfType(GeoImageLayer.class) 699 .forEach(geo -> action.accept(geo.getGpxCorrelateAction())); 700 } 701 702 private final transient StatusBarUpdater statusBarUpdater = new StatusBarUpdater(false); 703 private final transient StatusBarUpdater statusBarUpdaterWithRepaint = new StatusBarUpdater(true); 704 705 private class StatusBarUpdater implements DocumentListener, ItemListener, ChangeListener, ActionListener, GpxDataChangeListener { 706 private final boolean doRepaint; 707 708 StatusBarUpdater(boolean doRepaint) { 709 this.doRepaint = doRepaint; 710 } 711 712 @Override 713 public void insertUpdate(DocumentEvent e) { 714 matchAndUpdateStatusBar(); 715 } 716 717 @Override 718 public void removeUpdate(DocumentEvent e) { 719 matchAndUpdateStatusBar(); 720 } 721 722 @Override 723 public void changedUpdate(DocumentEvent e) { 724 // Do nothing 725 } 726 727 @Override 728 public void itemStateChanged(ItemEvent e) { 729 matchAndUpdateStatusBar(); 730 } 731 732 @Override 733 public void stateChanged(ChangeEvent e) { 734 matchAndUpdateStatusBar(); 735 } 736 737 @Override 738 public void actionPerformed(ActionEvent e) { 739 matchAndUpdateStatusBar(); 740 } 741 742 @Override 743 public void gpxDataChanged(GpxDataChangeEvent e) { 744 matchAndUpdateStatusBar(); 745 } 746 747 public void matchAndUpdateStatusBar() { 748 if (syncDialog != null && syncDialog.isVisible()) { 749 statusBarText.setText(matchAndGetStatusText()); 750 if (doRepaint) { 751 yLayer.updateBufferAndRepaint(); 752 } 753 } 754 } 755 756 private String matchAndGetStatusText() { 757 try { 758 timezone = GpxTimezone.parseTimezone(tfTimezone.getText().trim()); 759 delta = GpxTimeOffset.parseOffset(tfOffset.getText().trim()); 760 } catch (ParseException e) { 761 return e.getMessage(); 762 } 763 764 // The selection of images we are about to correlate may have changed. 765 // So reset all images. 766 yLayer.discardTmp(); 767 768 // Construct a list of images that have a date, and sort them on the date. 769 List<ImageEntry> dateImgLst = getSortedImgList(); 770 // Create a temporary copy for each image 771 dateImgLst.forEach(ie -> ie.createTmp().unflagNewGpsData()); 772 773 GpxDataWrapper selGpx = selectedGPX(false); 774 if (selGpx == null) 775 return tr("No gpx selected"); 776 777 final long offsetMs = ((long) (timezone.getHours() * TimeUnit.HOURS.toMillis(1))) + delta.getMilliseconds(); // in milliseconds 778 lastNumMatched = GpxImageCorrelation.matchGpxTrack(dateImgLst, selGpx.data, 779 pDirectionPosition.isVisible() ? 780 new GpxImageCorrelationSettings(offsetMs, forceTags, pDirectionPosition.getSettings()) : 781 new GpxImageCorrelationSettings(offsetMs, forceTags)); 782 783 return trn("<html>Matched <b>{0}</b> of <b>{1}</b> photo to GPX track.</html>", 784 "<html>Matched <b>{0}</b> of <b>{1}</b> photos to GPX track.</html>", 785 dateImgLst.size(), lastNumMatched, dateImgLst.size()); 786 } 787 } 788 789 static class RepaintTheMapListener implements FocusListener { 790 791 private final GeoImageLayer yLayer; 792 793 RepaintTheMapListener(GeoImageLayer yLayer) { 794 this.yLayer = Objects.requireNonNull(yLayer); 795 } 796 797 @Override 798 public void focusGained(FocusEvent e) { // do nothing 799 } 800 801 @Override 802 public void focusLost(FocusEvent e) { 803 yLayer.updateBufferAndRepaint(); 804 } 805 } 806 807 /** 808 * Presents dialog with sliders for manual adjust. 809 */ 810 private class AdjustActionListener implements ActionListener { 811 812 @Override 813 public void actionPerformed(ActionEvent e) { 814 815 final GpxTimeOffset offset = GpxTimeOffset.milliseconds( 816 delta.getMilliseconds() + Math.round(timezone.getHours() * TimeUnit.HOURS.toMillis(1))); 817 final int dayOffset = offset.getDayOffset(); 818 final Pair<GpxTimezone, GpxTimeOffset> timezoneOffsetPair = offset.withoutDayOffset().splitOutTimezone(); 819 820 // This is called whenever one of the sliders is moved. 821 // It calls the "match photos" code 822 AdjustListener listener = (tz, min, sec) -> { 823 timezone = tz; 824 825 delta = GpxTimeOffset.milliseconds(100L * sec 826 + TimeUnit.MINUTES.toMillis(min) 827 + TimeUnit.DAYS.toMillis(dayOffset)); 828 829 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater); 830 tfOffset.getDocument().removeDocumentListener(statusBarUpdater); 831 832 tfTimezone.setText(timezone.formatTimezone()); 833 tfOffset.setText(delta.formatOffset()); 834 835 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 836 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 837 838 statusBarUpdater.matchAndUpdateStatusBar(); 839 yLayer.updateBufferAndRepaint(); 840 841 return statusBarText.getText(); 842 }; 843 844 // There is no way to cancel this dialog, all changes get applied 845 // immediately. Therefore "Close" is marked with an "OK" icon. 846 // Settings are only saved temporarily to the layer. 847 new AdjustTimezoneAndOffsetDialog(MainApplication.getMainFrame(), 848 timezoneOffsetPair.a, timezoneOffsetPair.b, dayOffset) 849 .adjustListener(listener).showDialog(); 850 } 851 } 852 853 static class NoGpxTimestamps extends Exception { 854 } 855 856 void closeDialog() { 857 if (syncDialog != null) { 858 syncDialog.setVisible(false); 859 new SyncDialogWindowListener().windowDeactivated(null); 860 syncDialog.dispose(); 861 syncDialog = null; 862 } 863 } 864 865 void repaintCombobox() { 866 if (cbGpx != null) { 867 cbGpx.repaint(); 868 } 869 } 870 871 /** 872 * Tries to auto-guess the timezone and offset. 873 * 874 * @param imgs the images to correlate 875 * @param gpx the gpx track to correlate to 876 * @return a pair of timezone and offset 877 * @throws IndexOutOfBoundsException when there are no images 878 * @throws NoGpxTimestamps when the gpx track does not contain a timestamp 879 */ 880 static Pair<GpxTimezone, GpxTimeOffset> autoGuess(List<ImageEntry> imgs, GpxData gpx) throws NoGpxTimestamps { 881 882 // Init variables 883 long firstExifDate = imgs.get(0).getExifInstant().toEpochMilli(); 884 885 // Finds first GPX point 886 long firstGPXDate = gpx.tracks.stream() 887 .flatMap(trk -> trk.getSegments().stream()) 888 .flatMap(segment -> segment.getWayPoints().stream()) 889 .filter(WayPoint::hasDate) 890 .map(WayPoint::getTimeInMillis) 891 .findFirst() 892 .orElseThrow(NoGpxTimestamps::new); 893 894 return GpxTimeOffset.milliseconds(firstExifDate - firstGPXDate).splitOutTimezone(); 895 } 896 897 private class AutoGuessActionListener implements ActionListener { 898 899 @Override 900 public void actionPerformed(ActionEvent e) { 901 GpxDataWrapper gpxW = selectedGPX(true); 902 if (gpxW == null) 903 return; 904 GpxData gpx = gpxW.data; 905 906 List<ImageEntry> imgs = getSortedImgList(); 907 908 try { 909 final Pair<GpxTimezone, GpxTimeOffset> r = autoGuess(imgs, gpx); 910 timezone = r.a; 911 delta = r.b; 912 } catch (IndexOutOfBoundsException ex) { 913 Logging.debug(ex); 914 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), 915 tr("The selected photos do not contain time information."), 916 tr("Photos do not contain time information"), JOptionPane.WARNING_MESSAGE); 917 return; 918 } catch (NoGpxTimestamps ex) { 919 Logging.debug(ex); 920 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), 921 tr("The selected GPX track does not contain timestamps. Please select another one."), 922 tr("GPX Track has no time information"), JOptionPane.WARNING_MESSAGE); 923 return; 924 } 925 926 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater); 927 tfOffset.getDocument().removeDocumentListener(statusBarUpdater); 928 929 tfTimezone.setText(timezone.formatTimezone()); 930 tfOffset.setText(delta.formatOffset()); 931 tfOffset.requestFocus(); 932 933 tfTimezone.getDocument().addDocumentListener(statusBarUpdater); 934 tfOffset.getDocument().addDocumentListener(statusBarUpdater); 935 936 statusBarUpdater.matchAndUpdateStatusBar(); 937 yLayer.updateBufferAndRepaint(); 938 } 939 } 940 941 private List<ImageEntry> getSortedImgList() { 942 return yLayer.getSortedImgList(cbExifImg.isSelected(), cbTaggedImg.isSelected()); 943 } 944 945 private GpxDataWrapper selectedGPX(boolean complain) { 946 Object item = gpxModel.getSelectedItem(); 947 948 if (item == null || ((GpxDataWrapper) item).data == null) { 949 if (complain) { 950 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), tr("You should select a GPX track"), 951 tr("No selected GPX track"), JOptionPane.ERROR_MESSAGE); 952 } 953 return null; 954 } 955 return (GpxDataWrapper) item; 956 } 957 958 @Override 959 public void destroy() { 960 ExpertToggleAction.removeExpertModeChangeListener(this); 961 if (cbGpx != null) { 962 // Force the JCombobox to remove its eventListener from the static GpxDataWrapper 963 cbGpx.setModel(new DefaultComboBoxModel<GpxDataWrapper>()); 964 cbGpx = null; 965 } 966 967 closeDialog(); 968 969 outerPanel = null; 970 tfTimezone = null; 971 tfOffset = null; 972 cbExifImg = null; 973 cbTaggedImg = null; 974 cbShowThumbs = null; 975 statusBarText = null; 976 sepDirectionPosition = null; 977 pDirectionPosition = null; 978 } 979}