source: josm/trunk/src/org/openstreetmap/josm/gui/layer/geoimage/CorrelateGpxWithImages.java@ 18194

Last change on this file since 18194 was 18194, checked in by Don-vip, 3 years ago

see #21144 - use the selected GPX layer as support instead of the faux GPX data from geotagged images

  • Property svn:eol-style set to native
File size: 36.5 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.layer.geoimage;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5import static org.openstreetmap.josm.tools.I18n.trn;
6
7import java.awt.BorderLayout;
8import java.awt.Cursor;
9import java.awt.FlowLayout;
10import java.awt.Font;
11import java.awt.GraphicsEnvironment;
12import java.awt.GridBagConstraints;
13import java.awt.GridBagLayout;
14import java.awt.event.ActionEvent;
15import java.awt.event.ActionListener;
16import java.awt.event.FocusEvent;
17import java.awt.event.FocusListener;
18import java.awt.event.ItemEvent;
19import java.awt.event.ItemListener;
20import java.awt.event.WindowAdapter;
21import java.awt.event.WindowEvent;
22import java.beans.PropertyChangeEvent;
23import java.beans.PropertyChangeListener;
24import java.io.File;
25import java.text.ParseException;
26import java.util.List;
27import java.util.Objects;
28import java.util.Optional;
29import java.util.TimeZone;
30import java.util.concurrent.TimeUnit;
31import java.util.function.Consumer;
32
33import javax.swing.AbstractAction;
34import javax.swing.BorderFactory;
35import javax.swing.DefaultComboBoxModel;
36import javax.swing.JButton;
37import javax.swing.JCheckBox;
38import javax.swing.JLabel;
39import javax.swing.JOptionPane;
40import javax.swing.JPanel;
41import javax.swing.JSeparator;
42import javax.swing.MutableComboBoxModel;
43import javax.swing.SwingConstants;
44import javax.swing.event.ChangeEvent;
45import javax.swing.event.ChangeListener;
46import javax.swing.event.DocumentEvent;
47import javax.swing.event.DocumentListener;
48
49import org.openstreetmap.josm.actions.ExpertToggleAction;
50import org.openstreetmap.josm.actions.ExpertToggleAction.ExpertModeChangeListener;
51import org.openstreetmap.josm.data.gpx.GpxData;
52import org.openstreetmap.josm.data.gpx.GpxData.GpxDataChangeEvent;
53import org.openstreetmap.josm.data.gpx.GpxData.GpxDataChangeListener;
54import org.openstreetmap.josm.data.gpx.GpxDataContainer;
55import org.openstreetmap.josm.data.gpx.GpxImageCorrelation;
56import org.openstreetmap.josm.data.gpx.GpxImageCorrelationSettings;
57import org.openstreetmap.josm.data.gpx.GpxTimeOffset;
58import org.openstreetmap.josm.data.gpx.GpxTimezone;
59import org.openstreetmap.josm.data.gpx.WayPoint;
60import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
61import org.openstreetmap.josm.gui.ExtendedDialog;
62import org.openstreetmap.josm.gui.MainApplication;
63import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer;
64import org.openstreetmap.josm.gui.layer.Layer;
65import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
66import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
67import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
68import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent;
69import org.openstreetmap.josm.gui.layer.geoimage.AdjustTimezoneAndOffsetDialog.AdjustListener;
70import org.openstreetmap.josm.gui.layer.geoimage.SynchronizeTimeFromPhotoDialog.TimeZoneItem;
71import org.openstreetmap.josm.gui.layer.gpx.GpxDataHelper;
72import org.openstreetmap.josm.gui.widgets.JosmComboBox;
73import org.openstreetmap.josm.gui.widgets.JosmTextField;
74import org.openstreetmap.josm.spi.preferences.Config;
75import org.openstreetmap.josm.tools.Destroyable;
76import org.openstreetmap.josm.tools.GBC;
77import org.openstreetmap.josm.tools.ImageProvider;
78import org.openstreetmap.josm.tools.Logging;
79import org.openstreetmap.josm.tools.Pair;
80
81/**
82 * This class displays the window to select the GPX file and the offset (timezone + delta).
83 * Then it correlates the images of the layer with that GPX file.
84 * @since 2566
85 */
86public class CorrelateGpxWithImages extends AbstractAction implements ExpertModeChangeListener, Destroyable {
87
88 private static MutableComboBoxModel<GpxDataWrapper> gpxModel;
89 private static boolean forceTags;
90
91 private final transient GeoImageLayer yLayer;
92 private transient CorrelationSupportLayer supportLayer;
93 private transient GpxTimezone timezone;
94 private transient GpxTimeOffset delta;
95
96 /**
97 * Constructs a new {@code CorrelateGpxWithImages} action.
98 * @param layer The image layer
99 */
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 if (gpxModel.getElementAt(i).data.equals(removedGpxData)) {
383 gpxModel.removeElementAt(i);
384 forEachLayer(correlateAction -> {
385 correlateAction.repaintCombobox();
386 if (layer.equals(correlateAction.supportLayer)) {
387 correlateAction.supportLayer.getGpxData()
388 .removeChangeListener(correlateAction.statusBarUpdaterWithRepaint);
389 correlateAction.supportLayer = null;
390 correlateAction.buttonSupport.setEnabled(true);
391 }
392 });
393 break;
394 }
395 }
396 }
397 }
398
399 @Override
400 public void layerOrderChanged(LayerOrderChangeEvent e) {
401 // Not used
402 }
403 }
404
405 private static class GpxLayerRenamedListener implements PropertyChangeListener {
406 private final GpxDataWrapper gdw;
407 GpxLayerRenamedListener(GpxDataWrapper gdw) {
408 this.gdw = gdw;
409 }
410
411 @Override
412 public void propertyChange(PropertyChangeEvent e) {
413 if (Layer.NAME_PROP.equals(e.getPropertyName())) {
414 gdw.setName(e.getNewValue().toString());
415 }
416 }
417 }
418
419 /**
420 * Construct the list of loaded GPX tracks
421 * @param nogdw Data wrapper with no GPX data
422 */
423 private void constructGpxModel(NoGpxDataWrapper nogdw) {
424 gpxModel = new DefaultComboBoxModel<>();
425 GpxDataWrapper defaultItem = null;
426 for (AbstractModifiableLayer cur : MainApplication.getLayerManager().getLayersOfType(AbstractModifiableLayer.class)) {
427 if (cur instanceof GpxDataContainer) {
428 GpxData data = ((GpxDataContainer) cur).getGpxData();
429 GpxDataWrapper gdw = new GpxDataWrapper(cur.getName(), data, data.storageFile);
430 cur.addPropertyChangeListener(new GpxLayerRenamedListener(gdw));
431 gpxModel.addElement(gdw);
432 if (data.equals(yLayer.gpxData) || defaultItem == null) {
433 defaultItem = gdw;
434 }
435 }
436 }
437
438 if (gpxModel.getSize() == 0) {
439 gpxModel.addElement(nogdw);
440 } else if (defaultItem != null) {
441 gpxModel.setSelectedItem(defaultItem);
442 }
443 MainApplication.getLayerManager().addLayerChangeListener(new GpxLayerAddedListener());
444 }
445
446 static GpxTimezone loadTimezone() {
447 try {
448 String tz = Config.getPref().get("geoimage.timezone");
449 if (!tz.isEmpty()) {
450 return GpxTimezone.parseTimezone(tz);
451 } else {
452 return new GpxTimezone(TimeUnit.MILLISECONDS.toMinutes(TimeZone.getDefault().getRawOffset()) / 60.); //hours is double
453 }
454 } catch (ParseException e) {
455 Logging.trace(e);
456 return GpxTimezone.ZERO;
457 }
458 }
459
460 static GpxTimeOffset loadDelta() {
461 try {
462 return GpxTimeOffset.parseOffset(Config.getPref().get("geoimage.delta", "0"));
463 } catch (ParseException e) {
464 Logging.trace(e);
465 return GpxTimeOffset.ZERO;
466 }
467 }
468
469 @Override
470 public void actionPerformed(ActionEvent ae) {
471 NoGpxDataWrapper nogdw = new NoGpxDataWrapper();
472 if (gpxModel == null) {
473 constructGpxModel(nogdw);
474 }
475
476 JPanel panelCb = new JPanel();
477
478 panelCb.add(new JLabel(tr("GPX track: ")));
479
480 cbGpx = new JosmComboBox<>(gpxModel);
481 cbGpx.setPrototypeDisplayValue(nogdw);
482 cbGpx.addActionListener(statusBarUpdaterWithRepaint);
483 panelCb.add(cbGpx);
484
485 JButton buttonOpen = new JButton(tr("Open another GPX trace"));
486 buttonOpen.addActionListener(new LoadGpxDataActionListener());
487 panelCb.add(buttonOpen);
488
489 buttonSupport = new JButton(tr("Use support layer"));
490 buttonSupport.addActionListener(new UseSupportLayerActionListener());
491 panelCb.add(buttonSupport);
492
493 JPanel panelTf = new JPanel(new GridBagLayout());
494
495 timezone = loadTimezone();
496
497 tfTimezone = new JosmTextField(10);
498 tfTimezone.setText(timezone.formatTimezone());
499
500 delta = loadDelta();
501
502 tfOffset = new JosmTextField(10);
503 tfOffset.setText(delta.formatOffset());
504
505 JButton buttonViewGpsPhoto = new JButton(tr("<html>Use photo of an accurate clock,<br>e.g. GPS receiver display</html>"));
506 buttonViewGpsPhoto.setIcon(ImageProvider.get("clock"));
507 buttonViewGpsPhoto.addActionListener(new SetOffsetActionListener());
508
509 JButton buttonAutoGuess = new JButton(tr("Auto-Guess"));
510 buttonAutoGuess.setToolTipText(tr("Matches first photo with first gpx point"));
511 buttonAutoGuess.addActionListener(new AutoGuessActionListener());
512
513 JButton buttonAdjust = new JButton(tr("Manual adjust"));
514 buttonAdjust.addActionListener(new AdjustActionListener());
515
516 JButton buttonAdvanced = new JButton(tr("Advanced settings..."));
517 buttonAdvanced.addActionListener(new AdvancedSettingsActionListener());
518
519 JLabel labelPosition = new JLabel(tr("Override position for: "));
520
521 int numAll = yLayer.getSortedImgList(true, true).size();
522 int numExif = numAll - yLayer.getSortedImgList(false, true).size();
523 int numTagged = numAll - yLayer.getSortedImgList(true, false).size();
524
525 cbExifImg = new JCheckBox(tr("Images with geo location in exif data ({0}/{1})", numExif, numAll));
526 cbExifImg.setEnabled(numExif != 0);
527
528 cbTaggedImg = new JCheckBox(tr("Images that are already tagged ({0}/{1})", numTagged, numAll), true);
529 cbTaggedImg.setEnabled(numTagged != 0);
530
531 labelPosition.setEnabled(cbExifImg.isEnabled() || cbTaggedImg.isEnabled());
532
533 boolean ticked = yLayer.thumbsLoaded || Config.getPref().getBoolean("geoimage.showThumbs", false);
534 cbShowThumbs = new JCheckBox(tr("Show Thumbnail images on the map"), ticked);
535 cbShowThumbs.setEnabled(!yLayer.thumbsLoaded);
536
537 int y = 0;
538 GBC gbc = GBC.eol();
539 gbc.gridx = 0;
540 gbc.gridy = y++;
541 panelTf.add(panelCb, gbc);
542
543 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 12);
544 gbc.gridx = 0;
545 gbc.gridy = y++;
546 panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc);
547
548 gbc = GBC.std();
549 gbc.gridx = 0;
550 gbc.gridy = y;
551 panelTf.add(new JLabel(tr("Timezone: ")), gbc);
552
553 gbc = GBC.std().fill(GBC.HORIZONTAL);
554 gbc.gridx = 1;
555 gbc.gridy = y++;
556 gbc.weightx = 1.;
557 panelTf.add(tfTimezone, gbc);
558
559 gbc = GBC.std();
560 gbc.gridx = 0;
561 gbc.gridy = y;
562 panelTf.add(new JLabel(tr("Offset:")), gbc);
563
564 gbc = GBC.std().fill(GBC.HORIZONTAL);
565 gbc.gridx = 1;
566 gbc.gridy = y++;
567 gbc.weightx = 1.;
568 panelTf.add(tfOffset, gbc);
569
570 gbc = GBC.std().insets(5, 5, 5, 5);
571 gbc.gridx = 2;
572 gbc.gridy = y-2;
573 gbc.gridheight = 2;
574 gbc.gridwidth = 2;
575 gbc.fill = GridBagConstraints.BOTH;
576 gbc.weightx = 0.5;
577 panelTf.add(buttonViewGpsPhoto, gbc);
578
579 gbc = GBC.std().fill(GBC.BOTH).insets(5, 5, 5, 5);
580 gbc.gridx = 1;
581 gbc.gridy = y++;
582 gbc.weightx = 0.5;
583 panelTf.add(buttonAdvanced, gbc);
584
585 gbc.gridx = 2;
586 panelTf.add(buttonAutoGuess, gbc);
587
588 gbc.gridx = 3;
589 panelTf.add(buttonAdjust, gbc);
590
591 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 12, 0, 0);
592 gbc.gridx = 0;
593 gbc.gridy = y++;
594 panelTf.add(new JSeparator(SwingConstants.HORIZONTAL), gbc);
595
596 gbc = GBC.eol();
597 gbc.gridx = 0;
598 gbc.gridy = y++;
599 panelTf.add(labelPosition, gbc);
600
601 gbc = GBC.eol();
602 gbc.gridx = 1;
603 gbc.gridy = y++;
604 panelTf.add(cbExifImg, gbc);
605
606 gbc = GBC.eol();
607 gbc.gridx = 1;
608 gbc.gridy = y++;
609 panelTf.add(cbTaggedImg, gbc);
610
611 gbc = GBC.eol();
612 gbc.gridx = 0;
613 gbc.gridy = y;
614 panelTf.add(cbShowThumbs, gbc);
615
616 gbc = GBC.eol().fill(GBC.HORIZONTAL).insets(0, 12, 0, 0);
617 sepDirectionPosition = new JSeparator(SwingConstants.HORIZONTAL);
618 panelTf.add(sepDirectionPosition, gbc);
619
620 gbc = GBC.eol();
621 gbc.gridwidth = 3;
622 pDirectionPosition = ImageDirectionPositionPanel.forGpxTrace();
623 panelTf.add(pDirectionPosition, gbc);
624
625 expertChanged(ExpertToggleAction.isExpert());
626
627 final JPanel statusBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
628 statusBar.setBorder(BorderFactory.createLoweredBevelBorder());
629 statusBarText = new JLabel(" ");
630 statusBarText.setFont(statusBarText.getFont().deriveFont(Font.PLAIN, 8));
631 statusBar.add(statusBarText);
632
633 RepaintTheMapListener repaintTheMap = new RepaintTheMapListener(yLayer);
634 pDirectionPosition.addFocusListenerOnComponent(repaintTheMap);
635 tfTimezone.addFocusListener(repaintTheMap);
636 tfOffset.addFocusListener(repaintTheMap);
637
638 tfTimezone.getDocument().addDocumentListener(statusBarUpdater);
639 tfOffset.getDocument().addDocumentListener(statusBarUpdater);
640 cbExifImg.addItemListener(statusBarUpdaterWithRepaint);
641 cbTaggedImg.addItemListener(statusBarUpdaterWithRepaint);
642 pDirectionPosition.addChangeListenerOnComponents(statusBarUpdaterWithRepaint);
643 pDirectionPosition.addItemListenerOnComponents(statusBarUpdaterWithRepaint);
644
645 outerPanel = new JPanel(new BorderLayout());
646 outerPanel.add(statusBar, BorderLayout.PAGE_END);
647
648 if (!GraphicsEnvironment.isHeadless()) {
649 forEachLayer(CorrelateGpxWithImages::closeDialog);
650 syncDialog = new ExtendedDialog(
651 MainApplication.getMainFrame(),
652 tr("Correlate images with GPX track"),
653 new String[] {tr("Correlate"), tr("Cancel")},
654 false
655 );
656 syncDialog.setContent(panelTf, false);
657 syncDialog.setButtonIcons("ok", "cancel");
658 syncDialog.setupDialog();
659 outerPanel.add(syncDialog.getContentPane(), BorderLayout.PAGE_START);
660 syncDialog.setContentPane(outerPanel);
661 syncDialog.pack();
662 syncDialog.addWindowListener(new SyncDialogWindowListener());
663 syncDialog.showDialog();
664
665 statusBarUpdater.matchAndUpdateStatusBar();
666 yLayer.updateBufferAndRepaint();
667 }
668 }
669
670 @Override
671 public void expertChanged(boolean isExpert) {
672 if (buttonSupport != null) {
673 buttonSupport.setVisible(isExpert);
674 }
675 if (sepDirectionPosition != null) {
676 sepDirectionPosition.setVisible(isExpert);
677 }
678 if (pDirectionPosition != null) {
679 pDirectionPosition.setVisible(isExpert);
680 }
681 if (syncDialog != null) {
682 syncDialog.pack();
683 }
684 }
685
686 private static void removeDuplicates(File file) {
687 for (int i = gpxModel.getSize() - 1; i >= 0; i--) {
688 GpxDataWrapper wrapper = gpxModel.getElementAt(i);
689 if (wrapper instanceof NoGpxDataWrapper || (file != null && file.equals(wrapper.file))) {
690 gpxModel.removeElement(wrapper);
691 }
692 }
693 }
694
695 private static void forEachLayer(Consumer<CorrelateGpxWithImages> action) {
696 MainApplication.getLayerManager().getLayersOfType(GeoImageLayer.class)
697 .forEach(geo -> action.accept(geo.getGpxCorrelateAction()));
698 }
699
700 private final transient StatusBarUpdater statusBarUpdater = new StatusBarUpdater(false);
701 private final transient StatusBarUpdater statusBarUpdaterWithRepaint = new StatusBarUpdater(true);
702
703 private class StatusBarUpdater implements DocumentListener, ItemListener, ChangeListener, ActionListener, GpxDataChangeListener {
704 private final boolean doRepaint;
705
706 StatusBarUpdater(boolean doRepaint) {
707 this.doRepaint = doRepaint;
708 }
709
710 @Override
711 public void insertUpdate(DocumentEvent e) {
712 matchAndUpdateStatusBar();
713 }
714
715 @Override
716 public void removeUpdate(DocumentEvent e) {
717 matchAndUpdateStatusBar();
718 }
719
720 @Override
721 public void changedUpdate(DocumentEvent e) {
722 // Do nothing
723 }
724
725 @Override
726 public void itemStateChanged(ItemEvent e) {
727 matchAndUpdateStatusBar();
728 }
729
730 @Override
731 public void stateChanged(ChangeEvent e) {
732 matchAndUpdateStatusBar();
733 }
734
735 @Override
736 public void actionPerformed(ActionEvent e) {
737 matchAndUpdateStatusBar();
738 }
739
740 @Override
741 public void gpxDataChanged(GpxDataChangeEvent e) {
742 matchAndUpdateStatusBar();
743 }
744
745 public void matchAndUpdateStatusBar() {
746 if (syncDialog != null && syncDialog.isVisible()) {
747 statusBarText.setText(matchAndGetStatusText());
748 if (doRepaint) {
749 yLayer.updateBufferAndRepaint();
750 }
751 }
752 }
753
754 private String matchAndGetStatusText() {
755 try {
756 timezone = GpxTimezone.parseTimezone(tfTimezone.getText().trim());
757 delta = GpxTimeOffset.parseOffset(tfOffset.getText().trim());
758 } catch (ParseException e) {
759 return e.getMessage();
760 }
761
762 // The selection of images we are about to correlate may have changed.
763 // So reset all images.
764 yLayer.discardTmp();
765
766 // Construct a list of images that have a date, and sort them on the date.
767 List<ImageEntry> dateImgLst = getSortedImgList();
768 // Create a temporary copy for each image
769 dateImgLst.forEach(ie -> ie.createTmp().unflagNewGpsData());
770
771 GpxDataWrapper selGpx = selectedGPX(false);
772 if (selGpx == null)
773 return tr("No gpx selected");
774
775 final long offsetMs = ((long) (timezone.getHours() * TimeUnit.HOURS.toMillis(1))) + delta.getMilliseconds(); // in milliseconds
776 lastNumMatched = GpxImageCorrelation.matchGpxTrack(dateImgLst, selGpx.data,
777 pDirectionPosition.isVisible() ?
778 new GpxImageCorrelationSettings(offsetMs, forceTags, pDirectionPosition.getSettings()) :
779 new GpxImageCorrelationSettings(offsetMs, forceTags));
780
781 return trn("<html>Matched <b>{0}</b> of <b>{1}</b> photo to GPX track.</html>",
782 "<html>Matched <b>{0}</b> of <b>{1}</b> photos to GPX track.</html>",
783 dateImgLst.size(), lastNumMatched, dateImgLst.size());
784 }
785 }
786
787 static class RepaintTheMapListener implements FocusListener {
788
789 private final GeoImageLayer yLayer;
790
791 RepaintTheMapListener(GeoImageLayer yLayer) {
792 this.yLayer = Objects.requireNonNull(yLayer);
793 }
794
795 @Override
796 public void focusGained(FocusEvent e) { // do nothing
797 }
798
799 @Override
800 public void focusLost(FocusEvent e) {
801 yLayer.updateBufferAndRepaint();
802 }
803 }
804
805 /**
806 * Presents dialog with sliders for manual adjust.
807 */
808 private class AdjustActionListener implements ActionListener {
809
810 @Override
811 public void actionPerformed(ActionEvent e) {
812
813 final GpxTimeOffset offset = GpxTimeOffset.milliseconds(
814 delta.getMilliseconds() + Math.round(timezone.getHours() * TimeUnit.HOURS.toMillis(1)));
815 final int dayOffset = offset.getDayOffset();
816 final Pair<GpxTimezone, GpxTimeOffset> timezoneOffsetPair = offset.withoutDayOffset().splitOutTimezone();
817
818 // This is called whenever one of the sliders is moved.
819 // It calls the "match photos" code
820 AdjustListener listener = (tz, min, sec) -> {
821 timezone = tz;
822
823 delta = GpxTimeOffset.milliseconds(100L * sec
824 + TimeUnit.MINUTES.toMillis(min)
825 + TimeUnit.DAYS.toMillis(dayOffset));
826
827 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater);
828 tfOffset.getDocument().removeDocumentListener(statusBarUpdater);
829
830 tfTimezone.setText(timezone.formatTimezone());
831 tfOffset.setText(delta.formatOffset());
832
833 tfTimezone.getDocument().addDocumentListener(statusBarUpdater);
834 tfOffset.getDocument().addDocumentListener(statusBarUpdater);
835
836 statusBarUpdater.matchAndUpdateStatusBar();
837 yLayer.updateBufferAndRepaint();
838
839 return statusBarText.getText();
840 };
841
842 // There is no way to cancel this dialog, all changes get applied
843 // immediately. Therefore "Close" is marked with an "OK" icon.
844 // Settings are only saved temporarily to the layer.
845 new AdjustTimezoneAndOffsetDialog(MainApplication.getMainFrame(),
846 timezoneOffsetPair.a, timezoneOffsetPair.b, dayOffset)
847 .adjustListener(listener).showDialog();
848 }
849 }
850
851 static class NoGpxTimestamps extends Exception {
852 }
853
854 void closeDialog() {
855 if (syncDialog != null) {
856 syncDialog.setVisible(false);
857 new SyncDialogWindowListener().windowDeactivated(null);
858 syncDialog.dispose();
859 syncDialog = null;
860 }
861 }
862
863 void repaintCombobox() {
864 if (cbGpx != null) {
865 cbGpx.repaint();
866 }
867 }
868
869 /**
870 * Tries to auto-guess the timezone and offset.
871 *
872 * @param imgs the images to correlate
873 * @param gpx the gpx track to correlate to
874 * @return a pair of timezone and offset
875 * @throws IndexOutOfBoundsException when there are no images
876 * @throws NoGpxTimestamps when the gpx track does not contain a timestamp
877 */
878 static Pair<GpxTimezone, GpxTimeOffset> autoGuess(List<ImageEntry> imgs, GpxData gpx) throws NoGpxTimestamps {
879
880 // Init variables
881 long firstExifDate = imgs.get(0).getExifInstant().toEpochMilli();
882
883 // Finds first GPX point
884 long firstGPXDate = gpx.tracks.stream()
885 .flatMap(trk -> trk.getSegments().stream())
886 .flatMap(segment -> segment.getWayPoints().stream())
887 .filter(WayPoint::hasDate)
888 .map(WayPoint::getTimeInMillis)
889 .findFirst()
890 .orElseThrow(NoGpxTimestamps::new);
891
892 return GpxTimeOffset.milliseconds(firstExifDate - firstGPXDate).splitOutTimezone();
893 }
894
895 private class AutoGuessActionListener implements ActionListener {
896
897 @Override
898 public void actionPerformed(ActionEvent e) {
899 GpxDataWrapper gpxW = selectedGPX(true);
900 if (gpxW == null)
901 return;
902 GpxData gpx = gpxW.data;
903
904 List<ImageEntry> imgs = getSortedImgList();
905
906 try {
907 final Pair<GpxTimezone, GpxTimeOffset> r = autoGuess(imgs, gpx);
908 timezone = r.a;
909 delta = r.b;
910 } catch (IndexOutOfBoundsException ex) {
911 Logging.debug(ex);
912 JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
913 tr("The selected photos do not contain time information."),
914 tr("Photos do not contain time information"), JOptionPane.WARNING_MESSAGE);
915 return;
916 } catch (NoGpxTimestamps ex) {
917 Logging.debug(ex);
918 JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
919 tr("The selected GPX track does not contain timestamps. Please select another one."),
920 tr("GPX Track has no time information"), JOptionPane.WARNING_MESSAGE);
921 return;
922 }
923
924 tfTimezone.getDocument().removeDocumentListener(statusBarUpdater);
925 tfOffset.getDocument().removeDocumentListener(statusBarUpdater);
926
927 tfTimezone.setText(timezone.formatTimezone());
928 tfOffset.setText(delta.formatOffset());
929 tfOffset.requestFocus();
930
931 tfTimezone.getDocument().addDocumentListener(statusBarUpdater);
932 tfOffset.getDocument().addDocumentListener(statusBarUpdater);
933
934 statusBarUpdater.matchAndUpdateStatusBar();
935 yLayer.updateBufferAndRepaint();
936 }
937 }
938
939 private List<ImageEntry> getSortedImgList() {
940 return yLayer.getSortedImgList(cbExifImg.isSelected(), cbTaggedImg.isSelected());
941 }
942
943 private GpxDataWrapper selectedGPX(boolean complain) {
944 Object item = gpxModel.getSelectedItem();
945
946 if (item == null || ((GpxDataWrapper) item).data == null) {
947 if (complain) {
948 JOptionPane.showMessageDialog(MainApplication.getMainFrame(), tr("You should select a GPX track"),
949 tr("No selected GPX track"), JOptionPane.ERROR_MESSAGE);
950 }
951 return null;
952 }
953 return (GpxDataWrapper) item;
954 }
955
956 @Override
957 public void destroy() {
958 ExpertToggleAction.removeExpertModeChangeListener(this);
959 if (cbGpx != null) {
960 // Force the JCombobox to remove its eventListener from the static GpxDataWrapper
961 cbGpx.setModel(new DefaultComboBoxModel<GpxDataWrapper>());
962 cbGpx = null;
963 }
964
965 closeDialog();
966
967 outerPanel = null;
968 tfTimezone = null;
969 tfOffset = null;
970 cbExifImg = null;
971 cbTaggedImg = null;
972 cbShowThumbs = null;
973 statusBarText = null;
974 sepDirectionPosition = null;
975 pDirectionPosition = null;
976 }
977}
Note: See TracBrowser for help on using the repository browser.