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