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&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}