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.Component;
008import java.awt.Dimension;
009import java.awt.GridBagLayout;
010import java.util.Dictionary;
011import java.util.Hashtable;
012
013import javax.swing.JLabel;
014import javax.swing.JOptionPane;
015import javax.swing.JPanel;
016import javax.swing.JSlider;
017import javax.swing.event.ChangeListener;
018
019import org.openstreetmap.josm.data.gpx.GpxTimeOffset;
020import org.openstreetmap.josm.data.gpx.GpxTimezone;
021import org.openstreetmap.josm.gui.ExtendedDialog;
022import org.openstreetmap.josm.gui.MainApplication;
023import org.openstreetmap.josm.tools.GBC;
024import org.openstreetmap.josm.tools.JosmRuntimeException;
025import org.openstreetmap.josm.tools.Logging;
026
027/**
028 * Dialog used to manually adjust timezone and offset for GPX correlation.
029 * @since 18043 (extracted from {@link CorrelateGpxWithImages})
030 */
031public class AdjustTimezoneAndOffsetDialog extends ExtendedDialog {
032
033    private AdjustListener listener;
034
035    /**
036     * Constructs a new {@code AdjustTimezoneAndOffsetDialog}
037     * @param parent The parent element that will be used for position and maximum size
038     * @param tz initial timezone
039     * @param offset initial time offset
040     * @param dayOffset days offset
041     */
042    public AdjustTimezoneAndOffsetDialog(Component parent, GpxTimezone tz, GpxTimeOffset offset, int dayOffset) {
043        super(parent, tr("Adjust timezone and offset"), tr("Close"));
044        setContent(buildContent(tz, offset, dayOffset));
045        setButtonIcons("ok");
046    }
047
048    private Component buildContent(GpxTimezone a, GpxTimeOffset b, int dayOffset) {
049        // Info Labels
050        final JLabel lblMatches = new JLabel();
051
052        // Timezone Slider
053        // The slider allows to switch timezone from -12:00 to 12:00 in 30 minutes steps. Therefore the range is -24 to 24.
054        final JLabel lblTimezone = new JLabel();
055        final JSlider sldTimezone = new JSlider(-24, 24, 0);
056        sldTimezone.setPaintLabels(true);
057        Dictionary<Integer, JLabel> labelTable = new Hashtable<>();
058        // CHECKSTYLE.OFF: ParenPad
059        for (int i = -12; i <= 12; i += 6) {
060            labelTable.put(i * 2, new JLabel(new GpxTimezone(i).formatTimezone()));
061        }
062        // CHECKSTYLE.ON: ParenPad
063        sldTimezone.setLabelTable(labelTable);
064
065        // Minutes Slider
066        final JLabel lblMinutes = new JLabel();
067        final JSlider sldMinutes = new JSlider(-15, 15, 0);
068        sldMinutes.setPaintLabels(true);
069        sldMinutes.setMajorTickSpacing(5);
070
071        // Seconds slider
072        final JLabel lblSeconds = new JLabel();
073        final JSlider sldSeconds = new JSlider(-600, 600, 0);
074        sldSeconds.setPaintLabels(true);
075        labelTable = new Hashtable<>();
076        // CHECKSTYLE.OFF: ParenPad
077        for (int i = -60; i <= 60; i += 30) {
078            labelTable.put(i * 10, new JLabel(GpxTimeOffset.seconds(i).formatOffset()));
079        }
080        // CHECKSTYLE.ON: ParenPad
081        sldSeconds.setLabelTable(labelTable);
082        sldSeconds.setMajorTickSpacing(300);
083
084        // Put everything together
085        JPanel p = new JPanel(new GridBagLayout());
086        p.setPreferredSize(new Dimension(400, 230));
087        p.add(lblMatches, GBC.eol().fill());
088        p.add(lblTimezone, GBC.eol().fill());
089        p.add(sldTimezone, GBC.eol().fill().insets(0, 0, 0, 10));
090        p.add(lblMinutes, GBC.eol().fill());
091        p.add(sldMinutes, GBC.eol().fill().insets(0, 0, 0, 10));
092        p.add(lblSeconds, GBC.eol().fill());
093        p.add(sldSeconds, GBC.eol().fill());
094
095        // If there's an error in the calculation the found values
096        // will be off range for the sliders. Catch this error
097        // and inform the user about it.
098        try {
099            sldTimezone.setValue((int) (a.getHours() * 2));
100            sldMinutes.setValue((int) (b.getSeconds() / 60));
101            final long deciSeconds = b.getMilliseconds() / 100;
102            sldSeconds.setValue((int) (deciSeconds % 600));
103        } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException ex) {
104            Logging.warn(ex);
105            JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
106                    tr("An error occurred while trying to match the photos to the GPX track."
107                            +" You can adjust the sliders to manually match the photos."),
108                            tr("Matching photos to track failed"),
109                            JOptionPane.WARNING_MESSAGE);
110        }
111
112        // This is called whenever one of the sliders is moved.
113        // It updates the labels
114        ChangeListener sliderListener = x -> {
115            final GpxTimezone timezone = new GpxTimezone(sldTimezone.getValue() / 2.);
116            final int minutes = sldMinutes.getValue();
117            final int seconds = sldSeconds.getValue();
118
119            lblTimezone.setText(tr("Timezone: {0}", timezone.formatTimezone()));
120            lblMinutes.setText(tr("Minutes: {0}", minutes));
121            lblSeconds.setText(tr("Seconds: {0}", GpxTimeOffset.milliseconds(100L * seconds).formatOffset()));
122
123            StringBuilder sb = new StringBuilder("<html>");
124            if (listener != null) {
125                sb.append(listener.valuesChanged(timezone, minutes, seconds)).append("<br>");
126            }
127
128            lblMatches.setText(sb.append(trn("(Time difference of {0} day)", "Time difference of {0} days",
129                    Math.abs(dayOffset), Math.abs(dayOffset))).append("</html>").toString());
130        };
131
132        // Call the sliderListener once manually so labels get adjusted
133        sliderListener.stateChanged(null);
134
135        // Listeners added here, otherwise it tries to match three times
136        // (when setting the default values)
137        sldTimezone.addChangeListener(sliderListener);
138        sldMinutes.addChangeListener(sliderListener);
139        sldSeconds.addChangeListener(sliderListener);
140
141        return p;
142    }
143
144    /**
145     * Listener called when the sliders are moved.
146     */
147    public interface AdjustListener {
148        /**
149         * Provides a textual description matching the new state after the change of values.
150         * @param timezone new timezone
151         * @param minutes new minutes offset
152         * @param seconds new seconds offset
153         * @return an HTML textual description matching the new state after the change of values
154         */
155        String valuesChanged(GpxTimezone timezone, int minutes, int seconds);
156    }
157
158    /**
159     * Sets the {@link AdjustListener}.
160     * @param listener adjust listener, can be null
161     * @return {@code this}
162     */
163    public final AdjustTimezoneAndOffsetDialog adjustListener(AdjustListener listener) {
164        this.listener = listener;
165        return this;
166    }
167}