// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.gui.bbox;
import static org.openstreetmap.josm.tools.I18n.tr;
import java.awt.AWTKeyStroke;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.FlowLayout;
import java.awt.Graphics;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.KeyboardFocusManager;
import java.awt.Point;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.KeyEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.swing.AbstractAction;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.KeyStroke;
import javax.swing.SpinnerNumberModel;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.text.JTextComponent;
import org.openstreetmap.gui.jmapviewer.JMapViewer;
import org.openstreetmap.gui.jmapviewer.MapMarkerDot;
import org.openstreetmap.gui.jmapviewer.OsmTileLoader;
import org.openstreetmap.gui.jmapviewer.interfaces.MapMarker;
import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
import org.openstreetmap.josm.Main;
import org.openstreetmap.josm.data.Bounds;
import org.openstreetmap.josm.data.Version;
import org.openstreetmap.josm.data.coor.LatLon;
import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator;
import org.openstreetmap.josm.gui.widgets.HtmlPanel;
import org.openstreetmap.josm.gui.widgets.JosmTextField;
import org.openstreetmap.josm.gui.widgets.SelectAllOnFocusGainedDecorator;
import org.openstreetmap.josm.tools.ImageProvider;
/**
* TileSelectionBBoxChooser allows to select a bounding box (i.e. for downloading) based
* on OSM tile numbers.
*
* TileSelectionBBoxChooser can be embedded as component in a Swing container. Example:
* <pre>
* JFrame f = new JFrame(....);
* f.getContentPane().setLayout(new BorderLayout()));
* TileSelectionBBoxChooser chooser = new TileSelectionBBoxChooser();
* f.add(chooser, BorderLayout.CENTER);
* chooser.addPropertyChangeListener(new PropertyChangeListener() {
* public void propertyChange(PropertyChangeEvent evt) {
* // listen for BBOX events
* if (evt.getPropertyName().equals(BBoxChooser.BBOX_PROP)) {
* Main.info("new bbox based on OSM tiles selected: " + (Bounds)evt.getNewValue());
* }
* }
* });
*
* // init the chooser with a bounding box
* chooser.setBoundingBox(....);
*
* f.setVisible(true);
* </pre>
*/
public class TileSelectionBBoxChooser extends JPanel implements BBoxChooser{
/** the current bounding box */
private Bounds bbox;
/** the map viewer showing the selected bounding box */
private TileBoundsMapView mapViewer;
/** a panel for entering a bounding box given by a tile grid and a zoom level */
private TileGridInputPanel pnlTileGrid;
/** a panel for entering a bounding box given by the address of an individual OSM tile at
* a given zoom level
*/
private TileAddressInputPanel pnlTileAddress;
/**
* builds the UI
*/
protected final void build() {
setLayout(new GridBagLayout());
GridBagConstraints gc = new GridBagConstraints();
gc.weightx = 0.5;
gc.fill = GridBagConstraints.HORIZONTAL;
gc.anchor = GridBagConstraints.NORTHWEST;
add(pnlTileGrid = new TileGridInputPanel(), gc);
gc.gridx = 1;
add(pnlTileAddress = new TileAddressInputPanel(), gc);
gc.gridx = 0;
gc.gridy = 1;
gc.gridwidth = 2;
gc.weightx = 1.0;
gc.weighty = 1.0;
gc.fill = GridBagConstraints.BOTH;
gc.insets = new Insets(2,2,2,2);
add(mapViewer = new TileBoundsMapView(), gc);
mapViewer.setFocusable(false);
mapViewer.setZoomContolsVisible(false);
mapViewer.setMapMarkerVisible(false);
pnlTileAddress.addPropertyChangeListener(pnlTileGrid);
pnlTileGrid.addPropertyChangeListener(new TileBoundsChangeListener());
}
/**
* Constructs a new {@code TileSelectionBBoxChooser}.
*/
public TileSelectionBBoxChooser() {
build();
}
/**
* Replies the current bounding box. null, if no valid bounding box is currently selected.
*
*/
@Override
public Bounds getBoundingBox() {
return bbox;
}
/**
* Sets the current bounding box.
*
* @param bbox the bounding box. null, if this widget isn't initialized with a bounding box
*/
@Override
public void setBoundingBox(Bounds bbox) {
pnlTileGrid.initFromBoundingBox(bbox);
}
protected void refreshMapView() {
if (bbox == null) return;
// calc the screen coordinates for the new selection rectangle
MapMarkerDot xmin_ymin = new MapMarkerDot(bbox.getMinLat(), bbox.getMinLon());
MapMarkerDot xmax_ymax = new MapMarkerDot(bbox.getMaxLat(), bbox.getMaxLon());
List<MapMarker> marker = new ArrayList<>(2);
marker.add(xmin_ymin);
marker.add(xmax_ymax);
mapViewer.setBoundingBox(bbox);
mapViewer.setMapMarkerList(marker);
mapViewer.setDisplayToFitMapMarkers();
mapViewer.zoomOut();
}
/**
* Computes the bounding box given a tile grid.
*
* @param tb the description of the tile grid
* @return the bounding box
*/
protected Bounds convertTileBoundsToBoundingBox(TileBounds tb) {
LatLon min = getNorthWestLatLonOfTile(tb.min, tb.zoomLevel);
Point p = new Point(tb.max);
p.x++;
p.y++;
LatLon max = getNorthWestLatLonOfTile(p, tb.zoomLevel);
return new Bounds(max.lat(), min.lon(), min.lat(), max.lon());
}
/**
* Replies lat/lon of the north/west-corner of a tile at a specific zoom level
*
* @param tile the tile address (x,y)
* @param zoom the zoom level
* @return lat/lon of the north/west-corner of a tile at a specific zoom level
*/
protected LatLon getNorthWestLatLonOfTile(Point tile, int zoom) {
double lon = tile.x / Math.pow(2.0, zoom) * 360.0 - 180;
double lat = Math.toDegrees(Math.atan(Math.sinh(Math.PI - (2.0 * Math.PI * tile.y) / Math.pow(2.0, zoom))));
return new LatLon(lat, lon);
}
/**
* Listens to changes in the selected tile bounds, refreshes the map view and emits
* property change events for {@link BBoxChooser#BBOX_PROP}
*/
class TileBoundsChangeListener implements PropertyChangeListener {
@Override
public void propertyChange(PropertyChangeEvent evt) {
if (!evt.getPropertyName().equals(TileGridInputPanel.TILE_BOUNDS_PROP)) return;
TileBounds tb = (TileBounds)evt.getNewValue();
Bounds oldValue = TileSelectionBBoxChooser.this.bbox;
TileSelectionBBoxChooser.this.bbox = convertTileBoundsToBoundingBox(tb);
firePropertyChange(BBOX_PROP, oldValue, TileSelectionBBoxChooser.this.bbox);
refreshMapView();
}
}
/**
* A panel for describing a rectangular area of OSM tiles at a given zoom level.
*
* The panel emits PropertyChangeEvents for the property {@link TileGridInputPanel#TILE_BOUNDS_PROP}
* when the user successfully enters a valid tile grid specification.
*
*/
private static class TileGridInputPanel extends JPanel implements PropertyChangeListener{
public static final String TILE_BOUNDS_PROP = TileGridInputPanel.class.getName() + ".tileBounds";
private JosmTextField tfMaxY;
private JosmTextField tfMinY;
private JosmTextField tfMaxX;
private JosmTextField tfMinX;
private TileCoordinateValidator valMaxY;
private TileCoordinateValidator valMinY;
private TileCoordinateValidator valMaxX;
private TileCoordinateValidator valMinX;
private JSpinner spZoomLevel;
private TileBoundsBuilder tileBoundsBuilder = new TileBoundsBuilder();
private boolean doFireTileBoundChanged = true;
protected JPanel buildTextPanel() {
JPanel pnl = new JPanel(new BorderLayout());
HtmlPanel msg = new HtmlPanel();
msg.setText(tr("<html>Please select a <strong>range of OSM tiles</strong> at a given zoom level.</html>"));
pnl.add(msg);
return pnl;
}
protected JPanel buildZoomLevelPanel() {
JPanel pnl = new JPanel(new FlowLayout(FlowLayout.LEFT));
pnl.add(new JLabel(tr("Zoom level:")));
pnl.add(spZoomLevel = new JSpinner(new SpinnerNumberModel(0,0,18,1)));
spZoomLevel.addChangeListener(new ZomeLevelChangeHandler());
spZoomLevel.addChangeListener(tileBoundsBuilder);
return pnl;
}
protected JPanel buildTileGridInputPanel() {
JPanel pnl = new JPanel(new GridBagLayout());
pnl.setBorder(BorderFactory.createEmptyBorder(2,2,2,2));
GridBagConstraints gc = new GridBagConstraints();
gc.anchor = GridBagConstraints.NORTHWEST;
gc.insets = new Insets(0, 0, 2, 2);
gc.gridwidth = 2;
gc.gridx = 1;
gc.fill = GridBagConstraints.HORIZONTAL;
pnl.add(buildZoomLevelPanel(), gc);
gc.gridwidth = 1;
gc.gridy = 1;
gc.gridx = 1;
pnl.add(new JLabel(tr("from tile")), gc);
gc.gridx = 2;
pnl.add(new JLabel(tr("up to tile")), gc);
gc.gridx = 0;
gc.gridy = 2;
gc.weightx = 0.0;
pnl.add(new JLabel("X:"), gc);
gc.gridx = 1;
gc.weightx = 0.5;
pnl.add(tfMinX = new JosmTextField(), gc);
valMinX = new TileCoordinateValidator(tfMinX);
SelectAllOnFocusGainedDecorator.decorate(tfMinX);
tfMinX.addActionListener(tileBoundsBuilder);
tfMinX.addFocusListener(tileBoundsBuilder);
gc.gridx = 2;
gc.weightx = 0.5;
pnl.add(tfMaxX = new JosmTextField(), gc);
valMaxX = new TileCoordinateValidator(tfMaxX);
SelectAllOnFocusGainedDecorator.decorate(tfMaxX);
tfMaxX.addActionListener(tileBoundsBuilder);
tfMaxX.addFocusListener(tileBoundsBuilder);
gc.gridx = 0;
gc.gridy = 3;
gc.weightx = 0.0;
pnl.add(new JLabel("Y:"), gc);
gc.gridx = 1;
gc.weightx = 0.5;
pnl.add(tfMinY = new JosmTextField(), gc);
valMinY = new TileCoordinateValidator(tfMinY);
SelectAllOnFocusGainedDecorator.decorate(tfMinY);
tfMinY.addActionListener(tileBoundsBuilder);
tfMinY.addFocusListener(tileBoundsBuilder);
gc.gridx = 2;
gc.weightx = 0.5;
pnl.add(tfMaxY = new JosmTextField(), gc);
valMaxY = new TileCoordinateValidator(tfMaxY);
SelectAllOnFocusGainedDecorator.decorate(tfMaxY);
tfMaxY.addActionListener(tileBoundsBuilder);
tfMaxY.addFocusListener(tileBoundsBuilder);
gc.gridy = 4;
gc.gridx = 0;
gc.gridwidth = 3;
gc.weightx = 1.0;
gc.weighty = 1.0;
gc.fill = GridBagConstraints.BOTH;
pnl.add(new JPanel(), gc);
return pnl;
}
protected void build() {
setLayout(new BorderLayout());
setBorder(BorderFactory.createEmptyBorder(5,5,5,5));
add(buildTextPanel(), BorderLayout.NORTH);
add(buildTileGridInputPanel(), BorderLayout.CENTER);
Set<AWTKeyStroke> forwardKeys = new HashSet<>(getFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS));
forwardKeys.add(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0));
setFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,forwardKeys);
}
public TileGridInputPanel() {
build();
}
public void initFromBoundingBox(Bounds bbox) {
if (bbox == null)
return;
TileBounds tb = new TileBounds();
tb.zoomLevel = (Integer) spZoomLevel.getValue();
tb.min = new Point(
Math.max(0,lonToTileX(tb.zoomLevel, bbox.getMinLon())),
Math.max(0,latToTileY(tb.zoomLevel, bbox.getMaxLat() - 0.00001))
);
tb.max = new Point(
Math.max(0,lonToTileX(tb.zoomLevel, bbox.getMaxLon())),
Math.max(0,latToTileY(tb.zoomLevel, bbox.getMinLat() - 0.00001))
);
doFireTileBoundChanged = false;
setTileBounds(tb);
doFireTileBoundChanged = true;
}
public static int latToTileY(int zoom, double lat) {
if ((zoom < 3) || (zoom > 18)) return -1;
double l = lat / 180 * Math.PI;
double pf = Math.log(Math.tan(l) + (1/Math.cos(l)));
return (int) ((1<<(zoom-1)) * (Math.PI - pf) / Math.PI);
}
public static int lonToTileX(int zoom, double lon) {
if ((zoom < 3) || (zoom > 18)) return -1;
return (int) ((1<<(zoom-3)) * (lon + 180.0) / 45.0);
}
public void setTileBounds(TileBounds tileBounds) {
tfMinX.setText(Integer.toString(tileBounds.min.x));
tfMinY.setText(Integer.toString(tileBounds.min.y));
tfMaxX.setText(Integer.toString(tileBounds.max.x));
tfMaxY.setText(Integer.toString(tileBounds.max.y));
spZoomLevel.setValue(tileBounds.zoomLevel);
}
@Override
public void propertyChange(PropertyChangeEvent evt) {
if (evt.getPropertyName().equals(TileAddressInputPanel.TILE_BOUNDS_PROP)) {
TileBounds tb = (TileBounds)evt.getNewValue();
setTileBounds(tb);
fireTileBoundsChanged(tb);
}
}
protected void fireTileBoundsChanged(TileBounds tb) {
if (!doFireTileBoundChanged) return;
firePropertyChange(TILE_BOUNDS_PROP, null, tb);
}
class ZomeLevelChangeHandler implements ChangeListener {
@Override
public void stateChanged(ChangeEvent e) {
int zoomLevel = (Integer)spZoomLevel.getValue();
valMaxX.setZoomLevel(zoomLevel);
valMaxY.setZoomLevel(zoomLevel);
valMinX.setZoomLevel(zoomLevel);
valMinY.setZoomLevel(zoomLevel);
}
}
class TileBoundsBuilder implements ActionListener, FocusListener, ChangeListener {
protected void buildTileBounds() {
if (!valMaxX.isValid()) return;
if (!valMaxY.isValid()) return;
if (!valMinX.isValid()) return;
if (!valMinY.isValid()) return;
Point min = new Point(valMinX.getTileIndex(), valMinY.getTileIndex());
Point max = new Point(valMaxX.getTileIndex(), valMaxY.getTileIndex());
int zoomlevel = (Integer)spZoomLevel.getValue();
TileBounds tb = new TileBounds(min, max, zoomlevel);
fireTileBoundsChanged(tb);
}
@Override
public void focusGained(FocusEvent e) {/* irrelevant */}
@Override
public void focusLost(FocusEvent e) {
buildTileBounds();
}
@Override
public void actionPerformed(ActionEvent e) {
buildTileBounds();
}
@Override
public void stateChanged(ChangeEvent e) {
buildTileBounds();
}
}
}
/**
* A panel for entering the address of a single OSM tile at a given zoom level.
*
*/
private static class TileAddressInputPanel extends JPanel {
public static final String TILE_BOUNDS_PROP = TileAddressInputPanel.class.getName() + ".tileBounds";
private JosmTextField tfTileAddress;
private TileAddressValidator valTileAddress;
protected JPanel buildTextPanel() {
JPanel pnl = new JPanel(new BorderLayout());
HtmlPanel msg = new HtmlPanel();
msg.setText(tr("<html>Alternatively you may enter a <strong>tile address</strong> for a single tile "
+ "in the format <i>zoomlevel/x/y</i>, i.e. <i>15/256/223</i>. Tile addresses "
+ "in the format <i>zoom,x,y</i> or <i>zoom;x;y</i> are valid too.</html>"));
pnl.add(msg);
return pnl;
}
protected JPanel buildTileAddressInputPanel() {
JPanel pnl = new JPanel(new GridBagLayout());
GridBagConstraints gc = new GridBagConstraints();
gc.anchor = GridBagConstraints.NORTHWEST;
gc.fill = GridBagConstraints.HORIZONTAL;
gc.weightx = 0.0;
gc.insets = new Insets(0,0,2,2);
pnl.add(new JLabel(tr("Tile address:")), gc);
gc.weightx = 1.0;
gc.gridx = 1;
pnl.add(tfTileAddress = new JosmTextField(), gc);
valTileAddress = new TileAddressValidator(tfTileAddress);
SelectAllOnFocusGainedDecorator.decorate(tfTileAddress);
gc.weightx = 0.0;
gc.gridx = 2;
ApplyTileAddressAction applyTileAddressAction = new ApplyTileAddressAction();
JButton btn = new JButton(applyTileAddressAction);
btn.setBorder(BorderFactory.createEmptyBorder(1,1,1,1));
pnl.add(btn, gc);
tfTileAddress.addActionListener(applyTileAddressAction);
return pnl;
}
protected void build() {
setLayout(new GridBagLayout());
GridBagConstraints gc = new GridBagConstraints();
gc.anchor = GridBagConstraints.NORTHWEST;
gc.fill = GridBagConstraints.HORIZONTAL;
gc.weightx = 1.0;
gc.insets = new Insets(0,0,5,0);
add(buildTextPanel(), gc);
gc.gridy = 1;
add(buildTileAddressInputPanel(), gc);
// filler - grab remaining space
gc.gridy = 2;
gc.fill = GridBagConstraints.BOTH;
gc.weighty = 1.0;
add(new JPanel(), gc);
}
public TileAddressInputPanel() {
setBorder(BorderFactory.createEmptyBorder(5,5,5,5));
build();
}
protected void fireTileBoundsChanged(TileBounds tb){
firePropertyChange(TILE_BOUNDS_PROP, null, tb);
}
class ApplyTileAddressAction extends AbstractAction {
public ApplyTileAddressAction() {
putValue(SMALL_ICON, ImageProvider.get("apply"));
putValue(SHORT_DESCRIPTION, tr("Apply the tile address"));
}
@Override
public void actionPerformed(ActionEvent e) {
TileBounds tb = valTileAddress.getTileBounds();
if (tb != null) {
fireTileBoundsChanged(tb);
}
}
}
}
/**
* Validates a tile address
*/
private static class TileAddressValidator extends AbstractTextComponentValidator {
private TileBounds tileBounds = null;
public TileAddressValidator(JTextComponent tc) throws IllegalArgumentException {
super(tc);
}
@Override
public boolean isValid() {
String value = getComponent().getText().trim();
Matcher m = Pattern.compile("(\\d+)[^\\d]+(\\d+)[^\\d]+(\\d+)").matcher(value);
tileBounds = null;
if (!m.matches()) return false;
int zoom;
try {
zoom = Integer.parseInt(m.group(1));
} catch(NumberFormatException e){
return false;
}
if (zoom < 0 || zoom > 18) return false;
int x;
try {
x = Integer.parseInt(m.group(2));
} catch(NumberFormatException e){
return false;
}
if (x < 0 || x >= Math.pow(2, zoom)) return false;
int y;
try {
y = Integer.parseInt(m.group(3));
} catch(NumberFormatException e){
return false;
}
if (y < 0 || y >= Math.pow(2, zoom)) return false;
tileBounds = new TileBounds(new Point(x,y), new Point(x,y), zoom);
return true;
}
@Override
public void validate() {
if (isValid()) {
feedbackValid(tr("Please enter a tile address"));
} else {
feedbackInvalid(tr("The current value isn''t a valid tile address", getComponent().getText()));
}
}
public TileBounds getTileBounds() {
return tileBounds;
}
}
/**
* Validates the x- or y-coordinate of a tile at a given zoom level.
*
*/
private static class TileCoordinateValidator extends AbstractTextComponentValidator {
private int zoomLevel;
private int tileIndex;
public TileCoordinateValidator(JTextComponent tc) throws IllegalArgumentException {
super(tc);
}
public void setZoomLevel(int zoomLevel) {
this.zoomLevel = zoomLevel;
validate();
}
@Override
public boolean isValid() {
String value = getComponent().getText().trim();
try {
if (value.isEmpty()) {
tileIndex = 0;
} else {
tileIndex = Integer.parseInt(value);
}
} catch(NumberFormatException e) {
return false;
}
if (tileIndex < 0 || tileIndex >= Math.pow(2, zoomLevel)) return false;
return true;
}
@Override
public void validate() {
if (isValid()) {
feedbackValid(tr("Please enter a tile index"));
} else {
feedbackInvalid(tr("The current value isn''t a valid tile index for the given zoom level", getComponent().getText()));
}
}
public int getTileIndex() {
return tileIndex;
}
}
/**
* Represents a rectangular area of tiles at a given zoom level.
*
*/
private static class TileBounds {
public Point min;
public Point max;
public int zoomLevel;
public TileBounds() {
zoomLevel = 0;
min = new Point(0,0);
max = new Point(0,0);
}
public TileBounds(Point min, Point max, int zoomLevel) {
this.min = min;
this.max = max;
this.zoomLevel = zoomLevel;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("min=").append(min.x).append(",").append(min.y).append(",");
sb.append("max=").append(max.x).append(",").append(max.y).append(",");
sb.append("zoom=").append(zoomLevel);
return sb.toString();
}
}
/**
* The map view used in this bounding box chooser
*/
private static class TileBoundsMapView extends JMapViewer {
private Point min;
private Point max;
public TileBoundsMapView() {
setBorder(BorderFactory.createLineBorder(Color.DARK_GRAY));
TileLoader loader = tileController.getTileLoader();
if (loader instanceof OsmTileLoader) {
((OsmTileLoader)loader).headers.put("User-Agent", Version.getInstance().getFullAgentString());
}
}
public void setBoundingBox(Bounds bbox){
if (bbox == null) {
min = null;
max = null;
} else {
int y1 = tileSource.LatToY(bbox.getMinLat(), MAX_ZOOM);
int y2 = tileSource.LatToY(bbox.getMaxLat(), MAX_ZOOM);
int x1 = tileSource.LonToX(bbox.getMinLon(), MAX_ZOOM);
int x2 = tileSource.LonToX(bbox.getMaxLon(), MAX_ZOOM);
min = new Point(Math.min(x1, x2), Math.min(y1, y2));
max = new Point(Math.max(x1, x2), Math.max(y1, y2));
}
repaint();
}
protected Point getTopLeftCoordinates() {
return new Point(center.x - (getWidth() / 2), center.y - (getHeight() / 2));
}
/**
* Draw the map.
*/
@Override
public void paint(Graphics g) {
try {
super.paint(g);
if (min == null || max == null) return;
int zoomDiff = MAX_ZOOM - zoom;
Point tlc = getTopLeftCoordinates();
int x_min = (min.x >> zoomDiff) - tlc.x;
int y_min = (min.y >> zoomDiff) - tlc.y;
int x_max = (max.x >> zoomDiff) - tlc.x;
int y_max = (max.y >> zoomDiff) - tlc.y;
int w = x_max - x_min;
int h = y_max - y_min;
g.setColor(new Color(0.9f, 0.7f, 0.7f, 0.6f));
g.fillRect(x_min, y_min, w, h);
g.setColor(Color.BLACK);
g.drawRect(x_min, y_min, w, h);
} catch (Exception e) {
Main.error(e);
}
}
}
}