001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.bbox;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.AWTKeyStroke;
007import java.awt.BorderLayout;
008import java.awt.Color;
009import java.awt.FlowLayout;
010import java.awt.Graphics;
011import java.awt.GridBagConstraints;
012import java.awt.GridBagLayout;
013import java.awt.Insets;
014import java.awt.KeyboardFocusManager;
015import java.awt.Point;
016import java.awt.event.ActionEvent;
017import java.awt.event.ActionListener;
018import java.awt.event.FocusEvent;
019import java.awt.event.FocusListener;
020import java.awt.event.KeyEvent;
021import java.beans.PropertyChangeEvent;
022import java.beans.PropertyChangeListener;
023import java.util.ArrayList;
024import java.util.HashSet;
025import java.util.List;
026import java.util.Set;
027import java.util.regex.Matcher;
028import java.util.regex.Pattern;
029
030import javax.swing.AbstractAction;
031import javax.swing.BorderFactory;
032import javax.swing.JButton;
033import javax.swing.JLabel;
034import javax.swing.JPanel;
035import javax.swing.JSpinner;
036import javax.swing.KeyStroke;
037import javax.swing.SpinnerNumberModel;
038import javax.swing.event.ChangeEvent;
039import javax.swing.event.ChangeListener;
040import javax.swing.text.JTextComponent;
041
042import org.openstreetmap.gui.jmapviewer.JMapViewer;
043import org.openstreetmap.gui.jmapviewer.MapMarkerDot;
044import org.openstreetmap.gui.jmapviewer.OsmTileLoader;
045import org.openstreetmap.gui.jmapviewer.interfaces.MapMarker;
046import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
047import org.openstreetmap.josm.data.Bounds;
048import org.openstreetmap.josm.data.Version;
049import org.openstreetmap.josm.data.coor.LatLon;
050import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator;
051import org.openstreetmap.josm.gui.widgets.HtmlPanel;
052import org.openstreetmap.josm.gui.widgets.JosmTextField;
053import org.openstreetmap.josm.gui.widgets.SelectAllOnFocusGainedDecorator;
054import org.openstreetmap.josm.tools.ImageProvider;
055import org.openstreetmap.josm.tools.Utils;
056
057/**
058 * TileSelectionBBoxChooser allows to select a bounding box (i.e. for downloading) based
059 * on OSM tile numbers.
060 *
061 * TileSelectionBBoxChooser can be embedded as component in a Swing container. Example:
062 * <pre>
063 *    JFrame f = new JFrame(....);
064 *    f.getContentPane().setLayout(new BorderLayout()));
065 *    TileSelectionBBoxChooser chooser = new TileSelectionBBoxChooser();
066 *    f.add(chooser, BorderLayout.CENTER);
067 *    chooser.addPropertyChangeListener(new PropertyChangeListener() {
068 *        public void propertyChange(PropertyChangeEvent evt) {
069 *            // listen for BBOX events
070 *            if (evt.getPropertyName().equals(BBoxChooser.BBOX_PROP)) {
071 *               Logging.info("new bbox based on OSM tiles selected: " + (Bounds)evt.getNewValue());
072 *            }
073 *        }
074 *    });
075 *
076 *    // init the chooser with a bounding box
077 *    chooser.setBoundingBox(....);
078 *
079 *    f.setVisible(true);
080 * </pre>
081 */
082public class TileSelectionBBoxChooser extends JPanel implements BBoxChooser {
083
084    /** the current bounding box */
085    private transient Bounds bbox;
086    /** the map viewer showing the selected bounding box */
087    private final TileBoundsMapView mapViewer = new TileBoundsMapView();
088    /** a panel for entering a bounding box given by a  tile grid and a zoom level */
089    private final TileGridInputPanel pnlTileGrid = new TileGridInputPanel();
090    /** a panel for entering a bounding box given by the address of an individual OSM tile at a given zoom level */
091    private final TileAddressInputPanel pnlTileAddress = new TileAddressInputPanel();
092
093    /**
094     * builds the UI
095     */
096    protected final void build() {
097        setLayout(new GridBagLayout());
098
099        GridBagConstraints gc = new GridBagConstraints();
100        gc.weightx = 0.5;
101        gc.fill = GridBagConstraints.HORIZONTAL;
102        gc.anchor = GridBagConstraints.NORTHWEST;
103        add(pnlTileGrid, gc);
104
105        gc.gridx = 1;
106        add(pnlTileAddress, gc);
107
108        gc.gridx = 0;
109        gc.gridy = 1;
110        gc.gridwidth = 2;
111        gc.weightx = 1.0;
112        gc.weighty = 1.0;
113        gc.fill = GridBagConstraints.BOTH;
114        gc.insets = new Insets(2, 2, 2, 2);
115        add(mapViewer, gc);
116        mapViewer.setFocusable(false);
117        mapViewer.setZoomControlsVisible(false);
118        mapViewer.setMapMarkerVisible(false);
119
120        pnlTileAddress.addPropertyChangeListener(pnlTileGrid);
121        pnlTileGrid.addPropertyChangeListener(new TileBoundsChangeListener());
122    }
123
124    /**
125     * Constructs a new {@code TileSelectionBBoxChooser}.
126     */
127    public TileSelectionBBoxChooser() {
128        build();
129    }
130
131    /**
132     * Replies the current bounding box. null, if no valid bounding box is currently selected.
133     *
134     */
135    @Override
136    public Bounds getBoundingBox() {
137        return bbox;
138    }
139
140    /**
141     * Sets the current bounding box.
142     *
143     * @param bbox the bounding box. null, if this widget isn't initialized with a bounding box
144     */
145    @Override
146    public void setBoundingBox(Bounds bbox) {
147        pnlTileGrid.initFromBoundingBox(bbox);
148    }
149
150    protected void refreshMapView() {
151        if (bbox == null) return;
152
153        // calc the screen coordinates for the new selection rectangle
154        List<MapMarker> marker = new ArrayList<>(2);
155        marker.add(new MapMarkerDot(bbox.getMinLat(), bbox.getMinLon()));
156        marker.add(new MapMarkerDot(bbox.getMaxLat(), bbox.getMaxLon()));
157        mapViewer.setBoundingBox(bbox);
158        mapViewer.setMapMarkerList(marker);
159        mapViewer.setDisplayToFitMapMarkers();
160        mapViewer.zoomOut();
161    }
162
163    /**
164     * Computes the bounding box given a tile grid.
165     *
166     * @param tb the description of the tile grid
167     * @return the bounding box
168     */
169    protected Bounds convertTileBoundsToBoundingBox(TileBounds tb) {
170        LatLon min = getNorthWestLatLonOfTile(tb.min, tb.zoomLevel);
171        Point p = new Point(tb.max);
172        p.x++;
173        p.y++;
174        LatLon max = getNorthWestLatLonOfTile(p, tb.zoomLevel);
175        return new Bounds(max.lat(), min.lon(), min.lat(), max.lon());
176    }
177
178    /**
179     * Replies lat/lon of the north/west-corner of a tile at a specific zoom level
180     *
181     * @param tile  the tile address (x,y)
182     * @param zoom the zoom level
183     * @return lat/lon of the north/west-corner of a tile at a specific zoom level
184     */
185    protected LatLon getNorthWestLatLonOfTile(Point tile, int zoom) {
186        double lon = tile.x / Math.pow(2.0, zoom) * 360.0 - 180;
187        double lat = Utils.toDegrees(Math.atan(Math.sinh(Math.PI - (2.0 * Math.PI * tile.y) / Math.pow(2.0, zoom))));
188        return new LatLon(lat, lon);
189    }
190
191    /**
192     * Listens to changes in the selected tile bounds, refreshes the map view and emits
193     * property change events for {@link BBoxChooser#BBOX_PROP}
194     */
195    class TileBoundsChangeListener implements PropertyChangeListener {
196        @Override
197        public void propertyChange(PropertyChangeEvent evt) {
198            if (!evt.getPropertyName().equals(TileGridInputPanel.TILE_BOUNDS_PROP)) return;
199            TileBounds tb = (TileBounds) evt.getNewValue();
200            Bounds oldValue = TileSelectionBBoxChooser.this.bbox;
201            TileSelectionBBoxChooser.this.bbox = convertTileBoundsToBoundingBox(tb);
202            firePropertyChange(BBOX_PROP, oldValue, TileSelectionBBoxChooser.this.bbox);
203            refreshMapView();
204        }
205    }
206
207    /**
208     * A panel for describing a rectangular area of OSM tiles at a given zoom level.
209     *
210     * The panel emits PropertyChangeEvents for the property {@link TileGridInputPanel#TILE_BOUNDS_PROP}
211     * when the user successfully enters a valid tile grid specification.
212     *
213     */
214    private static class TileGridInputPanel extends JPanel implements PropertyChangeListener {
215        public static final String TILE_BOUNDS_PROP = TileGridInputPanel.class.getName() + ".tileBounds";
216
217        private final JosmTextField tfMaxY = new JosmTextField();
218        private final JosmTextField tfMinY = new JosmTextField();
219        private final JosmTextField tfMaxX = new JosmTextField();
220        private final JosmTextField tfMinX = new JosmTextField();
221        private transient TileCoordinateValidator valMaxY;
222        private transient TileCoordinateValidator valMinY;
223        private transient TileCoordinateValidator valMaxX;
224        private transient TileCoordinateValidator valMinX;
225        private final JSpinner spZoomLevel = new JSpinner(new SpinnerNumberModel(0, 0, 18, 1));
226        private final transient TileBoundsBuilder tileBoundsBuilder = new TileBoundsBuilder();
227        private boolean doFireTileBoundChanged = true;
228
229        protected JPanel buildTextPanel() {
230            JPanel pnl = new JPanel(new BorderLayout());
231            HtmlPanel msg = new HtmlPanel();
232            msg.setText(tr("<html>Please select a <strong>range of OSM tiles</strong> at a given zoom level.</html>"));
233            pnl.add(msg);
234            return pnl;
235        }
236
237        protected JPanel buildZoomLevelPanel() {
238            JPanel pnl = new JPanel(new FlowLayout(FlowLayout.LEFT));
239            pnl.add(new JLabel(tr("Zoom level:")));
240            pnl.add(spZoomLevel);
241            spZoomLevel.addChangeListener(new ZomeLevelChangeHandler());
242            spZoomLevel.addChangeListener(tileBoundsBuilder);
243            return pnl;
244        }
245
246        protected JPanel buildTileGridInputPanel() {
247            JPanel pnl = new JPanel(new GridBagLayout());
248            pnl.setBorder(BorderFactory.createEmptyBorder(2, 2, 2, 2));
249            GridBagConstraints gc = new GridBagConstraints();
250            gc.anchor = GridBagConstraints.NORTHWEST;
251            gc.insets = new Insets(0, 0, 2, 2);
252
253            gc.gridwidth = 2;
254            gc.gridx = 1;
255            gc.fill = GridBagConstraints.HORIZONTAL;
256            pnl.add(buildZoomLevelPanel(), gc);
257
258            gc.gridwidth = 1;
259            gc.gridy = 1;
260            gc.gridx = 1;
261            pnl.add(new JLabel(tr("from tile")), gc);
262
263            gc.gridx = 2;
264            pnl.add(new JLabel(tr("up to tile")), gc);
265
266            gc.gridx = 0;
267            gc.gridy = 2;
268            gc.weightx = 0.0;
269            pnl.add(new JLabel("X:"), gc);
270
271
272            gc.gridx = 1;
273            gc.weightx = 0.5;
274            pnl.add(tfMinX, gc);
275            valMinX = new TileCoordinateValidator(tfMinX);
276            SelectAllOnFocusGainedDecorator.decorate(tfMinX);
277            tfMinX.addActionListener(tileBoundsBuilder);
278            tfMinX.addFocusListener(tileBoundsBuilder);
279
280            gc.gridx = 2;
281            gc.weightx = 0.5;
282            pnl.add(tfMaxX, gc);
283            valMaxX = new TileCoordinateValidator(tfMaxX);
284            SelectAllOnFocusGainedDecorator.decorate(tfMaxX);
285            tfMaxX.addActionListener(tileBoundsBuilder);
286            tfMaxX.addFocusListener(tileBoundsBuilder);
287
288            gc.gridx = 0;
289            gc.gridy = 3;
290            gc.weightx = 0.0;
291            pnl.add(new JLabel("Y:"), gc);
292
293            gc.gridx = 1;
294            gc.weightx = 0.5;
295            pnl.add(tfMinY, gc);
296            valMinY = new TileCoordinateValidator(tfMinY);
297            SelectAllOnFocusGainedDecorator.decorate(tfMinY);
298            tfMinY.addActionListener(tileBoundsBuilder);
299            tfMinY.addFocusListener(tileBoundsBuilder);
300
301            gc.gridx = 2;
302            gc.weightx = 0.5;
303            pnl.add(tfMaxY, gc);
304            valMaxY = new TileCoordinateValidator(tfMaxY);
305            SelectAllOnFocusGainedDecorator.decorate(tfMaxY);
306            tfMaxY.addActionListener(tileBoundsBuilder);
307            tfMaxY.addFocusListener(tileBoundsBuilder);
308
309            gc.gridy = 4;
310            gc.gridx = 0;
311            gc.gridwidth = 3;
312            gc.weightx = 1.0;
313            gc.weighty = 1.0;
314            gc.fill = GridBagConstraints.BOTH;
315            pnl.add(new JPanel(), gc);
316            return pnl;
317        }
318
319        protected void build() {
320            setLayout(new BorderLayout());
321            setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
322            add(buildTextPanel(), BorderLayout.NORTH);
323            add(buildTileGridInputPanel(), BorderLayout.CENTER);
324
325            Set<AWTKeyStroke> forwardKeys = new HashSet<>(getFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS));
326            forwardKeys.add(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0));
327            setFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, forwardKeys);
328        }
329
330        TileGridInputPanel() {
331            build();
332        }
333
334        public void initFromBoundingBox(Bounds bbox) {
335            if (bbox == null)
336                return;
337            TileBounds tb = new TileBounds();
338            tb.zoomLevel = (Integer) spZoomLevel.getValue();
339            tb.min = new Point(
340                    Math.max(0, lonToTileX(tb.zoomLevel, bbox.getMinLon())),
341                    Math.max(0, latToTileY(tb.zoomLevel, bbox.getMaxLat() - 0.00001))
342            );
343            tb.max = new Point(
344                    Math.max(0, lonToTileX(tb.zoomLevel, bbox.getMaxLon())),
345                    Math.max(0, latToTileY(tb.zoomLevel, bbox.getMinLat() - 0.00001))
346            );
347            doFireTileBoundChanged = false;
348            setTileBounds(tb);
349            doFireTileBoundChanged = true;
350        }
351
352        public static int latToTileY(int zoom, double lat) {
353            if ((zoom < 3) || (zoom > 18)) return -1;
354            double l = lat / 180 * Math.PI;
355            double pf = Math.log(Math.tan(l) + (1/Math.cos(l)));
356            return (int) ((1 << (zoom-1)) * (Math.PI - pf) / Math.PI);
357        }
358
359        public static int lonToTileX(int zoom, double lon) {
360            if ((zoom < 3) || (zoom > 18)) return -1;
361            return (int) ((1 << (zoom-3)) * (lon + 180.0) / 45.0);
362        }
363
364        public void setTileBounds(TileBounds tileBounds) {
365            tfMinX.setText(Integer.toString(tileBounds.min.x));
366            tfMinY.setText(Integer.toString(tileBounds.min.y));
367            tfMaxX.setText(Integer.toString(tileBounds.max.x));
368            tfMaxY.setText(Integer.toString(tileBounds.max.y));
369            spZoomLevel.setValue(tileBounds.zoomLevel);
370        }
371
372        @Override
373        public void propertyChange(PropertyChangeEvent evt) {
374            if (evt.getPropertyName().equals(TileAddressInputPanel.TILE_BOUNDS_PROP)) {
375                TileBounds tb = (TileBounds) evt.getNewValue();
376                setTileBounds(tb);
377                fireTileBoundsChanged(tb);
378            }
379        }
380
381        protected void fireTileBoundsChanged(TileBounds tb) {
382            if (!doFireTileBoundChanged) return;
383            firePropertyChange(TILE_BOUNDS_PROP, null, tb);
384        }
385
386        class ZomeLevelChangeHandler implements ChangeListener {
387            @Override
388            public void stateChanged(ChangeEvent e) {
389                int zoomLevel = (Integer) spZoomLevel.getValue();
390                valMaxX.setZoomLevel(zoomLevel);
391                valMaxY.setZoomLevel(zoomLevel);
392                valMinX.setZoomLevel(zoomLevel);
393                valMinY.setZoomLevel(zoomLevel);
394            }
395        }
396
397        class TileBoundsBuilder implements ActionListener, FocusListener, ChangeListener {
398            protected void buildTileBounds() {
399                if (!valMaxX.isValid()) return;
400                if (!valMaxY.isValid()) return;
401                if (!valMinX.isValid()) return;
402                if (!valMinY.isValid()) return;
403                Point min = new Point(valMinX.getTileIndex(), valMinY.getTileIndex());
404                Point max = new Point(valMaxX.getTileIndex(), valMaxY.getTileIndex());
405                int zoomlevel = (Integer) spZoomLevel.getValue();
406                TileBounds tb = new TileBounds(min, max, zoomlevel);
407                fireTileBoundsChanged(tb);
408            }
409
410            @Override
411            public void focusGained(FocusEvent e) {
412                /* irrelevant */
413            }
414
415            @Override
416            public void focusLost(FocusEvent e) {
417                buildTileBounds();
418            }
419
420            @Override
421            public void actionPerformed(ActionEvent e) {
422                buildTileBounds();
423            }
424
425            @Override
426            public void stateChanged(ChangeEvent e) {
427                buildTileBounds();
428            }
429        }
430    }
431
432    /**
433     * A panel for entering the address of a single OSM tile at a given zoom level.
434     *
435     */
436    private static class TileAddressInputPanel extends JPanel {
437
438        public static final String TILE_BOUNDS_PROP = TileAddressInputPanel.class.getName() + ".tileBounds";
439
440        private transient TileAddressValidator valTileAddress;
441
442        protected JPanel buildTextPanel() {
443            JPanel pnl = new JPanel(new BorderLayout());
444            HtmlPanel msg = new HtmlPanel();
445            msg.setText(tr("<html>Alternatively you may enter a <strong>tile address</strong> for a single tile "
446                    + "in the format <i>zoomlevel/x/y</i>, e.g. <i>15/256/223</i>. Tile addresses "
447                    + "in the format <i>zoom,x,y</i> or <i>zoom;x;y</i> are valid too.</html>"));
448            pnl.add(msg);
449            return pnl;
450        }
451
452        protected JPanel buildTileAddressInputPanel() {
453            JPanel pnl = new JPanel(new GridBagLayout());
454            GridBagConstraints gc = new GridBagConstraints();
455            gc.anchor = GridBagConstraints.NORTHWEST;
456            gc.fill = GridBagConstraints.HORIZONTAL;
457            gc.weightx = 0.0;
458            gc.insets = new Insets(0, 0, 2, 2);
459            pnl.add(new JLabel(tr("Tile address:")), gc);
460
461            gc.weightx = 1.0;
462            gc.gridx = 1;
463            JosmTextField tfTileAddress = new JosmTextField();
464            pnl.add(tfTileAddress, gc);
465            valTileAddress = new TileAddressValidator(tfTileAddress);
466            SelectAllOnFocusGainedDecorator.decorate(tfTileAddress);
467
468            gc.weightx = 0.0;
469            gc.gridx = 2;
470            ApplyTileAddressAction applyTileAddressAction = new ApplyTileAddressAction();
471            JButton btn = new JButton(applyTileAddressAction);
472            btn.setBorder(BorderFactory.createEmptyBorder(1, 1, 1, 1));
473            pnl.add(btn, gc);
474            tfTileAddress.addActionListener(applyTileAddressAction);
475            return pnl;
476        }
477
478        protected void build() {
479            setLayout(new GridBagLayout());
480            GridBagConstraints gc = new GridBagConstraints();
481            gc.anchor = GridBagConstraints.NORTHWEST;
482            gc.fill = GridBagConstraints.HORIZONTAL;
483            gc.weightx = 1.0;
484            gc.insets = new Insets(0, 0, 5, 0);
485            add(buildTextPanel(), gc);
486
487            gc.gridy = 1;
488            add(buildTileAddressInputPanel(), gc);
489
490            // filler - grab remaining space
491            gc.gridy = 2;
492            gc.fill = GridBagConstraints.BOTH;
493            gc.weighty = 1.0;
494            add(new JPanel(), gc);
495        }
496
497        TileAddressInputPanel() {
498            setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
499            build();
500        }
501
502        protected void fireTileBoundsChanged(TileBounds tb) {
503            firePropertyChange(TILE_BOUNDS_PROP, null, tb);
504        }
505
506        class ApplyTileAddressAction extends AbstractAction {
507            ApplyTileAddressAction() {
508                new ImageProvider("apply").getResource().attachImageIcon(this, true);
509                putValue(SHORT_DESCRIPTION, tr("Apply the tile address"));
510            }
511
512            @Override
513            public void actionPerformed(ActionEvent e) {
514                TileBounds tb = valTileAddress.getTileBounds();
515                if (tb != null) {
516                    fireTileBoundsChanged(tb);
517                }
518            }
519        }
520    }
521
522    /**
523     * Validates a tile address
524     */
525    private static class TileAddressValidator extends AbstractTextComponentValidator {
526
527        private TileBounds tileBounds;
528
529        TileAddressValidator(JTextComponent tc) {
530            super(tc);
531        }
532
533        @Override
534        public boolean isValid() {
535            String value = getComponent().getText().trim();
536            Matcher m = Pattern.compile("(\\d+)[^\\d]+(\\d+)[^\\d]+(\\d+)").matcher(value);
537            tileBounds = null;
538            if (!m.matches()) return false;
539            int zoom;
540            try {
541                zoom = Integer.parseInt(m.group(1));
542            } catch (NumberFormatException e) {
543                return false;
544            }
545            if (zoom < 0 || zoom > 18) return false;
546
547            int x;
548            try {
549                x = Integer.parseInt(m.group(2));
550            } catch (NumberFormatException e) {
551                return false;
552            }
553            if (x < 0 || x >= Math.pow(2, zoom)) return false;
554            int y;
555            try {
556                y = Integer.parseInt(m.group(3));
557            } catch (NumberFormatException e) {
558                return false;
559            }
560            if (y < 0 || y >= Math.pow(2, zoom)) return false;
561
562            tileBounds = new TileBounds(new Point(x, y), new Point(x, y), zoom);
563            return true;
564        }
565
566        @Override
567        public void validate() {
568            if (isValid()) {
569                feedbackValid(tr("Please enter a tile address"));
570            } else {
571                feedbackInvalid(tr("The current value isn''t a valid tile address", getComponent().getText()));
572            }
573        }
574
575        public TileBounds getTileBounds() {
576            return tileBounds;
577        }
578    }
579
580    /**
581     * Validates the x- or y-coordinate of a tile at a given zoom level.
582     *
583     */
584    private static class TileCoordinateValidator extends AbstractTextComponentValidator {
585        private int zoomLevel;
586        private int tileIndex;
587
588        TileCoordinateValidator(JTextComponent tc) {
589            super(tc);
590        }
591
592        public void setZoomLevel(int zoomLevel) {
593            this.zoomLevel = zoomLevel;
594            validate();
595        }
596
597        @Override
598        public boolean isValid() {
599            String value = getComponent().getText().trim();
600            try {
601                if (value.isEmpty()) {
602                    tileIndex = 0;
603                } else {
604                    tileIndex = Integer.parseInt(value);
605                }
606            } catch (NumberFormatException e) {
607                return false;
608            }
609            return tileIndex >= 0 && tileIndex < Math.pow(2, zoomLevel);
610        }
611
612        @Override
613        public void validate() {
614            if (isValid()) {
615                feedbackValid(tr("Please enter a tile index"));
616            } else {
617                feedbackInvalid(tr("The current value isn''t a valid tile index for the given zoom level", getComponent().getText()));
618            }
619        }
620
621        public int getTileIndex() {
622            return tileIndex;
623        }
624    }
625
626    /**
627     * Represents a rectangular area of tiles at a given zoom level.
628     */
629    private static final class TileBounds {
630        private Point min;
631        private Point max;
632        private int zoomLevel;
633
634        private TileBounds() {
635            zoomLevel = 0;
636            min = new Point(0, 0);
637            max = new Point(0, 0);
638        }
639
640        private TileBounds(Point min, Point max, int zoomLevel) {
641            this.min = min;
642            this.max = max;
643            this.zoomLevel = zoomLevel;
644        }
645
646        @Override
647        public String toString() {
648            StringBuilder sb = new StringBuilder(24);
649            sb.append("min=").append(min.x).append(',').append(min.y)
650              .append(",max=").append(max.x).append(',').append(max.y)
651              .append(",zoom=").append(zoomLevel);
652            return sb.toString();
653        }
654    }
655
656    /**
657     * The map view used in this bounding box chooser
658     */
659    private static final class TileBoundsMapView extends JMapViewer {
660        private Point min;
661        private Point max;
662
663        private TileBoundsMapView() {
664            setBorder(BorderFactory.createLineBorder(Color.DARK_GRAY));
665            TileLoader loader = tileController.getTileLoader();
666            if (loader instanceof OsmTileLoader) {
667                ((OsmTileLoader) loader).headers.put("User-Agent", Version.getInstance().getFullAgentString());
668            }
669        }
670
671        public void setBoundingBox(Bounds bbox) {
672            if (bbox == null) {
673                min = null;
674                max = null;
675            } else {
676                Point p1 = tileSource.latLonToXY(bbox.getMinLat(), bbox.getMinLon(), MAX_ZOOM);
677                Point p2 = tileSource.latLonToXY(bbox.getMaxLat(), bbox.getMaxLon(), MAX_ZOOM);
678
679                min = new Point(Math.min(p1.x, p2.x), Math.min(p1.y, p2.y));
680                max = new Point(Math.max(p1.x, p2.x), Math.max(p1.y, p2.y));
681            }
682            repaint();
683        }
684
685        private Point getTopLeftCoordinates() {
686            return new Point(center.x - (getWidth() / 2), center.y - (getHeight() / 2));
687        }
688
689        /**
690         * Draw the map.
691         */
692        @Override
693        public void paint(Graphics g) {
694            super.paint(g);
695            if (min == null || max == null) return;
696            int zoomDiff = MAX_ZOOM - zoom;
697            Point tlc = getTopLeftCoordinates();
698            int xMin = (min.x >> zoomDiff) - tlc.x;
699            int yMin = (min.y >> zoomDiff) - tlc.y;
700            int xMax = (max.x >> zoomDiff) - tlc.x;
701            int yMax = (max.y >> zoomDiff) - tlc.y;
702
703            int w = xMax - xMin;
704            int h = yMax - yMin;
705            g.setColor(new Color(0.9f, 0.7f, 0.7f, 0.6f));
706            g.fillRect(xMin, yMin, w, h);
707
708            g.setColor(Color.BLACK);
709            g.drawRect(xMin, yMin, w, h);
710        }
711    }
712}