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}