001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.dialogs;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Color;
007import java.awt.Component;
008import java.awt.GridBagLayout;
009import java.awt.event.FocusEvent;
010import java.awt.event.FocusListener;
011import java.awt.event.WindowAdapter;
012import java.awt.event.WindowEvent;
013import java.util.Arrays;
014import java.util.Optional;
015
016import javax.swing.BorderFactory;
017import javax.swing.JLabel;
018import javax.swing.JPanel;
019import javax.swing.JSeparator;
020import javax.swing.JTabbedPane;
021import javax.swing.UIManager;
022import javax.swing.event.DocumentEvent;
023import javax.swing.event.DocumentListener;
024
025import org.openstreetmap.josm.data.coor.EastNorth;
026import org.openstreetmap.josm.data.coor.LatLon;
027import org.openstreetmap.josm.data.coor.conversion.CoordinateFormatManager;
028import org.openstreetmap.josm.data.coor.conversion.LatLonParser;
029import org.openstreetmap.josm.data.projection.ProjectionRegistry;
030import org.openstreetmap.josm.gui.ExtendedDialog;
031import org.openstreetmap.josm.gui.util.WindowGeometry;
032import org.openstreetmap.josm.gui.widgets.HtmlPanel;
033import org.openstreetmap.josm.gui.widgets.JosmTextField;
034import org.openstreetmap.josm.tools.GBC;
035import org.openstreetmap.josm.tools.Logging;
036import org.openstreetmap.josm.tools.Utils;
037
038/**
039 * A dialog that lets the user add a node at the coordinates he enters.
040 */
041public class LatLonDialog extends ExtendedDialog {
042    private static final Color BG_COLOR_ERROR = new Color(255, 224, 224);
043
044    /**
045     * The tabs that define the coordinate mode.
046     */
047    public JTabbedPane tabs;
048    private JosmTextField tfLatLon, tfEastNorth;
049    private LatLon latLonCoordinates;
050    private EastNorth eastNorthCoordinates;
051
052    protected JPanel buildLatLon() {
053        JPanel pnl = new JPanel(new GridBagLayout());
054        pnl.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
055
056        pnl.add(new JLabel(tr("Coordinates:")), GBC.std().insets(0, 10, 5, 0));
057        tfLatLon = new JosmTextField(24);
058        pnl.add(tfLatLon, GBC.eol().insets(0, 10, 0, 0).fill(GBC.HORIZONTAL).weight(1.0, 0.0));
059
060        pnl.add(new JSeparator(), GBC.eol().fill(GBC.HORIZONTAL).insets(0, 5, 0, 5));
061
062        pnl.add(new HtmlPanel(
063                String.join("<br/>",
064                        tr("Enter the coordinates for the new node."),
065                        tr("You can separate longitude and latitude with space, comma or semicolon."),
066                        tr("Use positive numbers or N, E characters to indicate North or East cardinal direction."),
067                        tr("For South and West cardinal directions you can use either negative numbers or S, W characters."),
068                        tr("Coordinate value can be in one of three formats:")
069                      ) +
070                Utils.joinAsHtmlUnorderedList(Arrays.asList(
071                        tr("<i>degrees</i><tt>&deg;</tt>"),
072                        tr("<i>degrees</i><tt>&deg;</tt> <i>minutes</i><tt>&#39;</tt>"),
073                        tr("<i>degrees</i><tt>&deg;</tt> <i>minutes</i><tt>&#39;</tt> <i>seconds</i><tt>&quot</tt>")
074                      )) +
075                String.join("<br/><br/>",
076                        tr("Symbols <tt>&deg;</tt>, <tt>&#39;</tt>, <tt>&prime;</tt>, <tt>&quot;</tt>, <tt>&Prime;</tt> are optional."),
077                        tr("You can also use the syntax <tt>lat=\"...\" lon=\"...\"</tt> or <tt>lat=''...'' lon=''...''</tt>."),
078                        tr("Some examples:")
079                      ) +
080                "<table><tr><td>" +
081                Utils.joinAsHtmlUnorderedList(Arrays.asList(
082                        "49.29918 19.24788",
083                        "49.29918, 19.24788",
084                        "49.29918&deg; 19.24788&deg;",
085                        "N 49.29918 E 19.24788",
086                        "W 49&deg;29.918&#39; S 19&deg;24.788&#39;",
087                        "N 49&deg;29&#39;04&quot; E 19&deg;24&#39;43&quot;",
088                        "49.29918 N, 19.24788 E",
089                        "49&deg;29&#39;21&quot; N 19&deg;24&#39;38&quot; E",
090                        "49 29 51, 19 24 18",
091                        "49 29, 19 24"
092                      )) +
093                "</td><td>" +
094                Utils.joinAsHtmlUnorderedList(Arrays.asList(
095                        "E 49 29, N 19 24",
096                        "49&deg; 29; 19&deg; 24",
097                        "N 49&deg; 29, W 19&deg; 24",
098                        "49&deg; 29.5 S, 19&deg; 24.6 E",
099                        "N 49 29.918 E 19 15.88",
100                        "49 29.4 19 24.5",
101                        "-49 29.4 N -19 24.5 W",
102                        "48 deg 42&#39; 52.13\" N, 21 deg 11&#39; 47.60\" E",
103                        "lat=\"49.29918\" lon=\"19.24788\"",
104                        "lat='49.29918' lon='19.24788'"
105                    )) +
106                "</td></tr></table>"),
107                GBC.eol().fill().weight(1.0, 1.0));
108
109        // parse and verify input on the fly
110        //
111        LatLonInputVerifier inputVerifier = new LatLonInputVerifier();
112        tfLatLon.getDocument().addDocumentListener(inputVerifier);
113
114        // select the text in the field on focus
115        //
116        TextFieldFocusHandler focusHandler = new TextFieldFocusHandler();
117        tfLatLon.addFocusListener(focusHandler);
118        return pnl;
119    }
120
121    private JPanel buildEastNorth() {
122        JPanel pnl = new JPanel(new GridBagLayout());
123        pnl.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
124
125        pnl.add(new JLabel(tr("Projected coordinates:")), GBC.std().insets(0, 10, 5, 0));
126        tfEastNorth = new JosmTextField(24);
127
128        pnl.add(tfEastNorth, GBC.eol().insets(0, 10, 0, 0).fill(GBC.HORIZONTAL).weight(1.0, 0.0));
129
130        pnl.add(new JSeparator(), GBC.eol().fill(GBC.HORIZONTAL).insets(0, 5, 0, 5));
131
132        pnl.add(new HtmlPanel(
133                tr("Enter easting and northing (x and y) separated by space, comma or semicolon.")),
134                GBC.eol().fill(GBC.HORIZONTAL));
135
136        pnl.add(GBC.glue(1, 1), GBC.eol().fill().weight(1.0, 1.0));
137
138        EastNorthInputVerifier inputVerifier = new EastNorthInputVerifier();
139        tfEastNorth.getDocument().addDocumentListener(inputVerifier);
140
141        TextFieldFocusHandler focusHandler = new TextFieldFocusHandler();
142        tfEastNorth.addFocusListener(focusHandler);
143
144        return pnl;
145    }
146
147    protected void build() {
148        tabs = new JTabbedPane();
149        tabs.addTab(tr("Lat/Lon"), buildLatLon());
150        tabs.addTab(tr("East/North"), buildEastNorth());
151        tabs.getModel().addChangeListener(e -> {
152            switch (tabs.getModel().getSelectedIndex()) {
153                case 0: parseLatLonUserInput(); break;
154                case 1: parseEastNorthUserInput(); break;
155                default: throw new AssertionError();
156            }
157        });
158        setContent(tabs, false);
159        addWindowListener(new WindowAdapter() {
160            @Override
161            public void windowOpened(WindowEvent e) {
162                tfLatLon.requestFocusInWindow();
163            }
164        });
165    }
166
167    /**
168     * Creates a new {@link LatLonDialog}
169     * @param parent The parent
170     * @param title The title of this dialog
171     * @param help The help text to use
172     */
173    public LatLonDialog(Component parent, String title, String help) {
174        super(parent, title, tr("Ok"), tr("Cancel"));
175        setButtonIcons("ok", "cancel");
176        configureContextsensitiveHelp(help, true);
177
178        build();
179        setCoordinates(null);
180    }
181
182    /**
183     * Check if lat/lon mode is active
184     * @return <code>true</code> iff the user selects lat/lon coordinates
185     */
186    public boolean isLatLon() {
187        return tabs.getModel().getSelectedIndex() == 0;
188    }
189
190    /**
191     * Sets the coordinate fields to the given coordinates
192     * @param ll The lat/lon coordinates
193     */
194    public void setCoordinates(LatLon ll) {
195        LatLon llc = Optional.ofNullable(ll).orElse(LatLon.ZERO);
196        tfLatLon.setText(CoordinateFormatManager.getDefaultFormat().latToString(llc) + ' ' +
197                         CoordinateFormatManager.getDefaultFormat().lonToString(llc));
198        EastNorth en = ProjectionRegistry.getProjection().latlon2eastNorth(llc);
199        tfEastNorth.setText(Double.toString(en.east()) + ' ' + Double.toString(en.north()));
200        // Both latLonCoordinates and eastNorthCoordinates may have been reset to null if ll is out of the world
201        latLonCoordinates = llc;
202        eastNorthCoordinates = en;
203        setOkEnabled(true);
204    }
205
206    /**
207     * Gets the coordinates that are entered by the user.
208     * @return The coordinates
209     */
210    public LatLon getCoordinates() {
211        if (isLatLon()) {
212            return latLonCoordinates;
213        } else {
214            if (eastNorthCoordinates == null) return null;
215            return ProjectionRegistry.getProjection().eastNorth2latlon(eastNorthCoordinates);
216        }
217    }
218
219    /**
220     * Gets the coordinates that are entered in the lat/lon field
221     * @return The lat/lon coordinates
222     */
223    public LatLon getLatLonCoordinates() {
224        return latLonCoordinates;
225    }
226
227    /**
228     * Gets the coordinates that are entered in the east/north field
229     * @return The east/north coordinates
230     */
231    public EastNorth getEastNorthCoordinates() {
232        return eastNorthCoordinates;
233    }
234
235    protected void setErrorFeedback(JosmTextField tf, String message) {
236        tf.setBorder(BorderFactory.createLineBorder(Color.RED, 1));
237        tf.setToolTipText(message);
238        tf.setBackground(BG_COLOR_ERROR);
239    }
240
241    protected void clearErrorFeedback(JosmTextField tf, String message) {
242        tf.setBorder(UIManager.getBorder("TextField.border"));
243        tf.setToolTipText(message);
244        tf.setBackground(UIManager.getColor("TextField.background"));
245    }
246
247    protected void parseLatLonUserInput() {
248        LatLon latLon;
249        try {
250            latLon = LatLonParser.parse(tfLatLon.getText());
251            if (!LatLon.isValidLat(latLon.lat()) || !LatLon.isValidLon(latLon.lon())) {
252                latLon = null;
253            }
254        } catch (IllegalArgumentException e) {
255            Logging.trace(e);
256            latLon = null;
257        }
258        if (latLon == null) {
259            setErrorFeedback(tfLatLon, tr("Please enter a GPS coordinates"));
260            latLonCoordinates = null;
261            setOkEnabled(false);
262        } else {
263            clearErrorFeedback(tfLatLon, tr("Please enter a GPS coordinates"));
264            latLonCoordinates = latLon;
265            setOkEnabled(true);
266        }
267    }
268
269    protected void parseEastNorthUserInput() {
270        EastNorth en;
271        try {
272            en = parseEastNorth(tfEastNorth.getText());
273        } catch (IllegalArgumentException e) {
274            Logging.trace(e);
275            en = null;
276        }
277        if (en == null) {
278            setErrorFeedback(tfEastNorth, tr("Please enter a Easting and Northing"));
279            latLonCoordinates = null;
280            setOkEnabled(false);
281        } else {
282            clearErrorFeedback(tfEastNorth, tr("Please enter a Easting and Northing"));
283            eastNorthCoordinates = en;
284            setOkEnabled(true);
285        }
286    }
287
288    private void setOkEnabled(boolean b) {
289        if (buttons != null && !buttons.isEmpty()) {
290            buttons.get(0).setEnabled(b);
291        }
292    }
293
294    @Override
295    public void setVisible(boolean visible) {
296        final String preferenceKey = getClass().getName() + ".geometry";
297        if (visible) {
298            new WindowGeometry(
299                    preferenceKey,
300                    WindowGeometry.centerInWindow(getParent(), getSize())
301            ).applySafe(this);
302        } else {
303            new WindowGeometry(this).remember(preferenceKey);
304        }
305        super.setVisible(visible);
306    }
307
308    class LatLonInputVerifier implements DocumentListener {
309        @Override
310        public void changedUpdate(DocumentEvent e) {
311            parseLatLonUserInput();
312        }
313
314        @Override
315        public void insertUpdate(DocumentEvent e) {
316            parseLatLonUserInput();
317        }
318
319        @Override
320        public void removeUpdate(DocumentEvent e) {
321            parseLatLonUserInput();
322        }
323    }
324
325    class EastNorthInputVerifier implements DocumentListener {
326        @Override
327        public void changedUpdate(DocumentEvent e) {
328            parseEastNorthUserInput();
329        }
330
331        @Override
332        public void insertUpdate(DocumentEvent e) {
333            parseEastNorthUserInput();
334        }
335
336        @Override
337        public void removeUpdate(DocumentEvent e) {
338            parseEastNorthUserInput();
339        }
340    }
341
342    static class TextFieldFocusHandler implements FocusListener {
343        @Override
344        public void focusGained(FocusEvent e) {
345            Component c = e.getComponent();
346            if (c instanceof JosmTextField) {
347                JosmTextField tf = (JosmTextField) c;
348                tf.selectAll();
349            }
350        }
351
352        @Override
353        public void focusLost(FocusEvent e) {
354            // Not used
355        }
356    }
357
358    /**
359     * Parses a east/north coordinate string
360     * @param s The coordinates. Dot has to be used as decimal separator, as comma can be used to delimit values
361     * @return The east/north coordinates or <code>null</code> on error.
362     */
363    public static EastNorth parseEastNorth(String s) {
364        String[] en = s.split("[;, ]+");
365        if (en.length != 2) return null;
366        try {
367            double east = Double.parseDouble(en[0]);
368            double north = Double.parseDouble(en[1]);
369            return new EastNorth(east, north);
370        } catch (NumberFormatException nfe) {
371            return null;
372        }
373    }
374
375    /**
376     * Gets the text entered in the lat/lon text field.
377     * @return The text the user entered
378     */
379    public String getLatLonText() {
380        return tfLatLon.getText();
381    }
382
383    /**
384     * Set the text in the lat/lon text field.
385     * @param text The new text
386     */
387    public void setLatLonText(String text) {
388        tfLatLon.setText(text);
389    }
390
391    /**
392     * Gets the text entered in the east/north text field.
393     * @return The text the user entered
394     */
395    public String getEastNorthText() {
396        return tfEastNorth.getText();
397    }
398
399    /**
400     * Set the text in the east/north text field.
401     * @param text The new text
402     */
403    public void setEastNorthText(String text) {
404        tfEastNorth.setText(text);
405    }
406}