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("&", -1);
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(",", -1);
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("/", -1);
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="https://github.com/openstreetmap/openstreetmap-website/blob/master/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 (int j = 0; j < shortLink.length(); j++) {
177            char ch = shortLink.charAt(j);
178            if (array.containsKey(ch)) {
179                int val = array.get(ch);
180                for (int i = 0; i < 3; ++i) {
181                    x <<= 1;
182                    if ((val & 32) != 0) {
183                        x |= 1;
184                    }
185                    val <<= 1;
186
187                    y <<= 1;
188                    if ((val & 32) != 0) {
189                        y |= 1;
190                    }
191                    val <<= 1;
192                }
193                zoom += 3;
194            } else {
195                zoomOffset--;
196            }
197        }
198
199        x <<= 32 - zoom;
200        y <<= 32 - zoom;
201
202        // 2**32 == 4294967296
203        return positionToBounds(y * 180.0 / 4294967296.0 - 90.0,
204                x * 360.0 / 4294967296.0 - 180.0,
205                // TODO: -2 was not in ruby code
206                zoom - 8 - (zoomOffset % 3) - 2);
207    }
208
209    /**
210     * Sets the map size supplier.
211     * @param mapSizeSupplier returns the map size in pixels
212     * @since 12796
213     */
214    public static void setMapSizeSupplier(Supplier<Dimension> mapSizeSupplier) {
215        mapSize = Objects.requireNonNull(mapSizeSupplier, "mapSizeSupplier");
216    }
217
218    private static final int TILE_SIZE_IN_PIXELS = 256;
219
220    /**
221     * Compute the bounds for a given lat/lon position and the zoom level
222     * @param lat The latitude
223     * @param lon The longitude
224     * @param zoom The current zoom level
225     * @return The bounds the OSM server would display
226     */
227    public static Bounds positionToBounds(final double lat, final double lon, final int zoom) {
228        final Dimension screenSize = mapSize.get();
229        double scale = (1L << zoom) * TILE_SIZE_IN_PIXELS / (2.0 * Math.PI * Ellipsoid.WGS84.a);
230        double deltaX = screenSize.getWidth() / 2.0 / scale;
231        double deltaY = screenSize.getHeight() / 2.0 / scale;
232        final Projection mercator = Projections.getProjectionByCode("EPSG:3857");
233        final EastNorth projected = mercator.latlon2eastNorth(new LatLon(lat, lon));
234        return new Bounds(
235                mercator.eastNorth2latlon(projected.add(-deltaX, -deltaY)),
236                mercator.eastNorth2latlon(projected.add(deltaX, deltaY)));
237    }
238
239    /**
240     * Return OSM Zoom level for a given area
241     *
242     * @param b bounds of the area
243     * @return matching zoom level for area
244     */
245    public static int getZoom(Bounds b) {
246        final Projection mercator = Projections.getProjectionByCode("EPSG:3857");
247        final EastNorth min = mercator.latlon2eastNorth(b.getMin());
248        final EastNorth max = mercator.latlon2eastNorth(b.getMax());
249        final double deltaX = max.getX() - min.getX();
250        final double scale = mapSize.get().getWidth() / deltaX;
251        final double x = scale * (2 * Math.PI * Ellipsoid.WGS84.a) / TILE_SIZE_IN_PIXELS;
252        return (int) Math.round(Math.log(x) / Math.log(2));
253    }
254
255    /**
256     * Return OSM URL for given area.
257     *
258     * @param b bounds of the area
259     * @return link to display that area in OSM map
260     */
261    public static String getURL(Bounds b) {
262        return getURL(b.getCenter(), getZoom(b));
263    }
264
265    /**
266     * Return OSM URL for given position and zoom.
267     *
268     * @param pos center position of area
269     * @param zoom zoom depth of display
270     * @return link to display that area in OSM map
271     */
272    public static String getURL(LatLon pos, int zoom) {
273        return getURL(pos.lat(), pos.lon(), zoom);
274    }
275
276    /**
277     * Return OSM URL for given lat/lon and zoom.
278     *
279     * @param dlat center latitude of area
280     * @param dlon center longitude of area
281     * @param zoom zoom depth of display
282     * @return link to display that area in OSM map
283     *
284     * @since 6453
285     */
286    public static String getURL(double dlat, double dlon, int zoom) {
287        // Truncate lat and lon to something more sensible
288        int decimals = (int) Math.pow(10, zoom / 3d);
289        double lat = Math.round(dlat * decimals);
290        lat /= decimals;
291        double lon = Math.round(dlon * decimals);
292        lon /= decimals;
293        return Config.getUrls().getOSMWebsite() + "/#map="+zoom+'/'+lat+'/'+lon;
294    }
295}