001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Dimension;
007import java.util.HashMap;
008import java.util.Map;
009import java.util.Objects;
010import java.util.function.Supplier;
011
012import org.openstreetmap.josm.data.Bounds;
013import org.openstreetmap.josm.data.coor.EastNorth;
014import org.openstreetmap.josm.data.coor.LatLon;
015import org.openstreetmap.josm.data.projection.Ellipsoid;
016import org.openstreetmap.josm.data.projection.Projection;
017import org.openstreetmap.josm.data.projection.Projections;
018import org.openstreetmap.josm.spi.preferences.Config;
019
020/**
021 * Parses various URL used in OpenStreetMap projects into {@link Bounds}.
022 */
023public final class OsmUrlToBounds {
024    private static final String SHORTLINK_PREFIX = "http://osm.org/go/";
025
026    private static volatile Supplier<Dimension> mapSize = () -> new Dimension(800, 600);
027
028    private OsmUrlToBounds() {
029        // Hide default constructor for utils classes
030    }
031
032    /**
033     * Parses an URL into {@link Bounds}
034     * @param url the URL to be parsed
035     * @return the parsed {@link Bounds}, or {@code null}
036     */
037    public static Bounds parse(String url) {
038        if (url.startsWith("geo:")) {
039            return GeoUrlToBounds.parse(url);
040        }
041        try {
042            // a percent sign indicates an encoded URL (RFC 1738).
043            if (url.contains("%")) {
044                url = Utils.decodeUrl(url);
045            }
046        } catch (IllegalArgumentException ex) {
047            Logging.error(ex);
048        }
049        Bounds b = parseShortLink(url);
050        if (b != null)
051            return b;
052        if (url.contains("#map") || url.contains("/#")) {
053            // probably it's a URL following the new scheme?
054            return parseHashURLs(url);
055        }
056        final int i = url.indexOf('?');
057        if (i == -1) {
058            return null;
059        }
060        String[] args = url.substring(i+1).split("&");
061        Map<String, String> map = new HashMap<>();
062        for (String arg : args) {
063            int eq = arg.indexOf('=');
064            if (eq != -1) {
065                map.put(arg.substring(0, eq), arg.substring(eq + 1));
066            }
067        }
068
069        try {
070            if (map.containsKey("bbox")) {
071                String[] bbox = map.get("bbox").split(",");
072                b = new Bounds(
073                        Double.parseDouble(bbox[1]), Double.parseDouble(bbox[0]),
074                        Double.parseDouble(bbox[3]), Double.parseDouble(bbox[2]));
075            } else if (map.containsKey("minlat")) {
076                double minlat = Double.parseDouble(map.get("minlat"));
077                double minlon = Double.parseDouble(map.get("minlon"));
078                double maxlat = Double.parseDouble(map.get("maxlat"));
079                double maxlon = Double.parseDouble(map.get("maxlon"));
080                b = new Bounds(minlat, minlon, maxlat, maxlon);
081            } else {
082                String z = map.get("zoom");
083                b = positionToBounds(parseDouble(map, "lat"), parseDouble(map, "lon"),
084                        z == null ? 18 : Integer.parseInt(z));
085            }
086        } catch (IllegalArgumentException | ArrayIndexOutOfBoundsException ex) {
087            Logging.log(Logging.LEVEL_ERROR, url, ex);
088        }
089        return b;
090    }
091
092    /**
093     * Openstreetmap.org changed it's URL scheme in August 2013, which breaks the URL parsing.
094     * The following function, called by the old parse function if necessary, provides parsing new URLs
095     * the new URLs follow the scheme https://www.openstreetmap.org/#map=18/51.71873/8.76164&amp;layers=CN
096     * @param url string for parsing
097     * @return Bounds if hashurl, {@code null} otherwise
098     */
099    private static Bounds parseHashURLs(String url) {
100        int startIndex = url.indexOf('#');
101        if (startIndex == -1) return null;
102        int endIndex = url.indexOf('&', startIndex);
103        if (endIndex == -1) endIndex = url.length();
104        String coordPart = url.substring(startIndex+(url.contains("#map=") ? "#map=".length() : "#".length()), endIndex);
105        String[] parts = coordPart.split("/");
106        if (parts.length < 3) {
107            Logging.warn(tr("URL does not contain {0}/{1}/{2}", tr("zoom"), tr("latitude"), tr("longitude")));
108            return null;
109        }
110        int zoom;
111        try {
112            zoom = Integer.parseInt(parts[0]);
113        } catch (NumberFormatException e) {
114            Logging.warn(tr("URL does not contain valid {0}", tr("zoom")), e);
115            return null;
116        }
117        double lat, lon;
118        try {
119            lat = Double.parseDouble(parts[1]);
120        } catch (NumberFormatException e) {
121            Logging.warn(tr("URL does not contain valid {0}", tr("latitude")), e);
122            return null;
123        }
124        try {
125            lon = Double.parseDouble(parts[2]);
126        } catch (NumberFormatException e) {
127            Logging.warn(tr("URL does not contain valid {0}", tr("longitude")), e);
128            return null;
129        }
130        return positionToBounds(lat, lon, zoom);
131    }
132
133    private static double parseDouble(Map<String, String> map, String key) {
134        if (map.containsKey(key))
135            return Double.parseDouble(map.get(key));
136        if (map.containsKey('m'+key))
137            return Double.parseDouble(map.get('m'+key));
138        throw new IllegalArgumentException(map.toString() + " does not contain " + key);
139    }
140
141    private static final char[] SHORTLINK_CHARS = {
142        'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H',
143        'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
144        'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
145        'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
146        'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n',
147        'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
148        'w', 'x', 'y', 'z', '0', '1', '2', '3',
149        '4', '5', '6', '7', '8', '9', '_', '@'
150    };
151
152    /**
153     * Parse OSM short link
154     *
155     * @param url string for parsing
156     * @return Bounds if shortlink, null otherwise
157     * @see <a href="http://trac.openstreetmap.org/browser/sites/rails_port/lib/short_link.rb">short_link.rb</a>
158     */
159    private static Bounds parseShortLink(final String url) {
160        if (!url.startsWith(SHORTLINK_PREFIX))
161            return null;
162        final String shortLink = url.substring(SHORTLINK_PREFIX.length());
163
164        final Map<Character, Integer> array = new HashMap<>();
165
166        for (int i = 0; i < SHORTLINK_CHARS.length; ++i) {
167            array.put(SHORTLINK_CHARS[i], i);
168        }
169
170        // long is necessary (need 32 bit positive value is needed)
171        long x = 0;
172        long y = 0;
173        int zoom = 0;
174        int zoomOffset = 0;
175
176        for (final char ch : shortLink.toCharArray()) {
177            if (array.containsKey(ch)) {
178                int val = array.get(ch);
179                for (int i = 0; i < 3; ++i) {
180                    x <<= 1;
181                    if ((val & 32) != 0) {
182                        x |= 1;
183                    }
184                    val <<= 1;
185
186                    y <<= 1;
187                    if ((val & 32) != 0) {
188                        y |= 1;
189                    }
190                    val <<= 1;
191                }
192                zoom += 3;
193            } else {
194                zoomOffset--;
195            }
196        }
197
198        x <<= 32 - zoom;
199        y <<= 32 - zoom;
200
201        // 2**32 == 4294967296
202        return positionToBounds(y * 180.0 / 4294967296.0 - 90.0,
203                x * 360.0 / 4294967296.0 - 180.0,
204                // TODO: -2 was not in ruby code
205                zoom - 8 - (zoomOffset % 3) - 2);
206    }
207
208    /**
209     * Sets the map size supplier.
210     * @param mapSizeSupplier returns the map size in pixels
211     * @since 12796
212     */
213    public static void setMapSizeSupplier(Supplier<Dimension> mapSizeSupplier) {
214        mapSize = Objects.requireNonNull(mapSizeSupplier, "mapSizeSupplier");
215    }
216
217    private static final int TILE_SIZE_IN_PIXELS = 256;
218
219    /**
220     * Compute the bounds for a given lat/lon position and the zoom level
221     * @param lat The latitude
222     * @param lon The longitude
223     * @param zoom The current zoom level
224     * @return The bounds the OSM server would display
225     */
226    public static Bounds positionToBounds(final double lat, final double lon, final int zoom) {
227        final Dimension screenSize = mapSize.get();
228        double scale = (1 << zoom) * TILE_SIZE_IN_PIXELS / (2 * Math.PI * Ellipsoid.WGS84.a);
229        double deltaX = screenSize.getWidth() / 2.0 / scale;
230        double deltaY = screenSize.getHeight() / 2.0 / scale;
231        final Projection mercator = Projections.getProjectionByCode("EPSG:3857");
232        final EastNorth projected = mercator.latlon2eastNorth(new LatLon(lat, lon));
233        return new Bounds(
234                mercator.eastNorth2latlon(projected.add(-deltaX, -deltaY)),
235                mercator.eastNorth2latlon(projected.add(deltaX, deltaY)));
236    }
237
238    /**
239     * Return OSM Zoom level for a given area
240     *
241     * @param b bounds of the area
242     * @return matching zoom level for area
243     */
244    public static int getZoom(Bounds b) {
245        final Projection mercator = Projections.getProjectionByCode("EPSG:3857");
246        final EastNorth min = mercator.latlon2eastNorth(b.getMin());
247        final EastNorth max = mercator.latlon2eastNorth(b.getMax());
248        final double deltaX = max.getX() - min.getX();
249        final double scale = mapSize.get().getWidth() / deltaX;
250        final double x = scale * (2 * Math.PI * Ellipsoid.WGS84.a) / TILE_SIZE_IN_PIXELS;
251        return (int) Math.round(Math.log(x) / Math.log(2));
252    }
253
254    /**
255     * Return OSM URL for given area.
256     *
257     * @param b bounds of the area
258     * @return link to display that area in OSM map
259     */
260    public static String getURL(Bounds b) {
261        return getURL(b.getCenter(), getZoom(b));
262    }
263
264    /**
265     * Return OSM URL for given position and zoom.
266     *
267     * @param pos center position of area
268     * @param zoom zoom depth of display
269     * @return link to display that area in OSM map
270     */
271    public static String getURL(LatLon pos, int zoom) {
272        return getURL(pos.lat(), pos.lon(), zoom);
273    }
274
275    /**
276     * Return OSM URL for given lat/lon and zoom.
277     *
278     * @param dlat center latitude of area
279     * @param dlon center longitude of area
280     * @param zoom zoom depth of display
281     * @return link to display that area in OSM map
282     *
283     * @since 6453
284     */
285    public static String getURL(double dlat, double dlon, int zoom) {
286        // Truncate lat and lon to something more sensible
287        int decimals = (int) Math.pow(10, zoom / 3d);
288        double lat = Math.round(dlat * decimals);
289        lat /= decimals;
290        double lon = Math.round(dlon * decimals);
291        lon /= decimals;
292        return Config.getUrls().getOSMWebsite() + "/#map="+zoom+'/'+lat+'/'+lon;
293    }
294}