001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.imagery; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.Point; 007import java.text.DecimalFormat; 008import java.text.DecimalFormatSymbols; 009import java.text.NumberFormat; 010import java.util.Locale; 011import java.util.Map; 012import java.util.Set; 013import java.util.TreeSet; 014import java.util.concurrent.ConcurrentHashMap; 015import java.util.regex.Matcher; 016import java.util.regex.Pattern; 017 018import org.openstreetmap.gui.jmapviewer.OsmMercator; 019import org.openstreetmap.gui.jmapviewer.Tile; 020import org.openstreetmap.gui.jmapviewer.TileXY; 021import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate; 022import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource; 023import org.openstreetmap.gui.jmapviewer.tilesources.TMSTileSource; 024import org.openstreetmap.josm.Main; 025import org.openstreetmap.josm.data.Bounds; 026import org.openstreetmap.josm.data.coor.EastNorth; 027import org.openstreetmap.josm.data.coor.LatLon; 028import org.openstreetmap.josm.data.projection.Projection; 029import org.openstreetmap.josm.gui.layer.WMSLayer; 030import org.openstreetmap.josm.tools.CheckParameterUtil; 031 032/** 033 * Tile Source handling WMS providers 034 * 035 * @author Wiktor Niesiobędzki 036 * @since 8526 037 */ 038public class TemplatedWMSTileSource extends TMSTileSource implements TemplatedTileSource { 039 private final Map<String, String> headers = new ConcurrentHashMap<>(); 040 private final Set<String> serverProjections; 041 private EastNorth topLeftCorner; 042 private Bounds worldBounds; 043 private int[] tileXMax; 044 private int[] tileYMax; 045 private double[] degreesPerTile; 046 047 private static final Pattern PATTERN_HEADER = Pattern.compile("\\{header\\(([^,]+),([^}]+)\\)\\}"); 048 private static final Pattern PATTERN_PROJ = Pattern.compile("\\{proj\\}"); 049 private static final Pattern PATTERN_BBOX = Pattern.compile("\\{bbox\\}"); 050 private static final Pattern PATTERN_W = Pattern.compile("\\{w\\}"); 051 private static final Pattern PATTERN_S = Pattern.compile("\\{s\\}"); 052 private static final Pattern PATTERN_E = Pattern.compile("\\{e\\}"); 053 private static final Pattern PATTERN_N = Pattern.compile("\\{n\\}"); 054 private static final Pattern PATTERN_WIDTH = Pattern.compile("\\{width\\}"); 055 private static final Pattern PATTERN_HEIGHT = Pattern.compile("\\{height\\}"); 056 private static final Pattern PATTERN_PARAM = Pattern.compile("\\{([^}]+)\\}"); 057 058 private static final NumberFormat latLonFormat = new DecimalFormat("###0.0000000", new DecimalFormatSymbols(Locale.US)); 059 060 private static final Pattern[] ALL_PATTERNS = { 061 PATTERN_HEADER, PATTERN_PROJ, PATTERN_BBOX, PATTERN_W, PATTERN_S, PATTERN_E, PATTERN_N, PATTERN_WIDTH, PATTERN_HEIGHT 062 }; 063 064 /* 065 * Constant taken from OGC WMTS Implementation Specification (http://www.opengeospatial.org/standards/wmts) 066 * From table E.4 - Definition of Well-known scale set GoogleMapsCompatibile 067 * 068 * As higher zoom levels have denominator divided by 2, we keep only zoom level 1 in the code 069 */ 070 private static final float SCALE_DENOMINATOR_ZOOM_LEVEL_1 = 559082264.0287178f; 071 072 /** 073 * Creates a tile source based on imagery info 074 * @param info imagery info 075 */ 076 public TemplatedWMSTileSource(ImageryInfo info) { 077 super(info); 078 this.serverProjections = new TreeSet<>(info.getServerProjections()); 079 handleTemplate(); 080 initProjection(); 081 // FIXME: remove in September 2015, when ImageryPreferenceEntry.tileSize will be initialized to -1 instead to 256 082 // need to leave it as it is to keep compatibility between tested and latest JOSM versions 083 tileSize = WMSLayer.PROP_IMAGE_SIZE.get(); 084 } 085 086 /** 087 * Initializes class with current projection in JOSM. This call is needed every time projection changes. 088 */ 089 public void initProjection() { 090 initProjection(Main.getProjection()); 091 } 092 093 /** 094 * Initializes class with projection in JOSM. This call is needed every time projection changes. 095 * @param proj new projection that shall be used for computations 096 */ 097 public void initProjection(Projection proj) { 098 this.worldBounds = getWorldBounds(); 099 EastNorth min = proj.latlon2eastNorth(worldBounds.getMin()); 100 EastNorth max = proj.latlon2eastNorth(worldBounds.getMax()); 101 this.topLeftCorner = new EastNorth(min.east(), max.north()); 102 103 LatLon bottomRight = new LatLon(worldBounds.getMinLat(), worldBounds.getMaxLon()); 104 105 // use 256 as "tile size" to keep the scale in line with default tiles in Mercator projection 106 double crsScale = 256 * 0.28e-03 / proj.getMetersPerUnit(); 107 tileXMax = new int[getMaxZoom() + 1]; 108 tileYMax = new int[getMaxZoom() + 1]; 109 degreesPerTile = new double[getMaxZoom() + 1]; 110 111 for (int zoom = 1; zoom <= getMaxZoom(); zoom++) { 112 // use well known scale set "GoogleCompatibile" from OGC WMTS spec to calculate number of tiles per zoom level 113 // this makes the zoom levels "glued" to standard TMS zoom levels 114 degreesPerTile[zoom] = (SCALE_DENOMINATOR_ZOOM_LEVEL_1 / Math.pow(2, zoom - 1)) * crsScale; 115 TileXY maxTileIndex = latLonToTileXY(bottomRight.toCoordinate(), zoom); 116 tileXMax[zoom] = maxTileIndex.getXIndex(); 117 tileYMax[zoom] = maxTileIndex.getYIndex(); 118 } 119 } 120 121 @Override 122 public int getDefaultTileSize() { 123 return WMSLayer.PROP_IMAGE_SIZE.get(); 124 } 125 126 @Override 127 public String getTileUrl(int zoom, int tilex, int tiley) { 128 String myProjCode = Main.getProjection().toCode(); 129 130 EastNorth nw = getTileEastNorth(tilex, tiley, zoom); 131 EastNorth se = getTileEastNorth(tilex + 1, tiley + 1, zoom); 132 133 double w = nw.getX(); 134 double n = nw.getY(); 135 136 double s = se.getY(); 137 double e = se.getX(); 138 139 if (!serverProjections.contains(myProjCode) && serverProjections.contains("EPSG:4326") && "EPSG:3857".equals(myProjCode)) { 140 LatLon swll = Main.getProjection().eastNorth2latlon(new EastNorth(w, s)); 141 LatLon nell = Main.getProjection().eastNorth2latlon(new EastNorth(e, n)); 142 myProjCode = "EPSG:4326"; 143 s = swll.lat(); 144 w = swll.lon(); 145 n = nell.lat(); 146 e = nell.lon(); 147 } 148 149 if ("EPSG:4326".equals(myProjCode) && !serverProjections.contains(myProjCode) && serverProjections.contains("CRS:84")) { 150 myProjCode = "CRS:84"; 151 } 152 153 // Bounding box coordinates have to be switched for WMS 1.3.0 EPSG:4326. 154 // 155 // Background: 156 // 157 // bbox=x_min,y_min,x_max,y_max 158 // 159 // SRS=... is WMS 1.1.1 160 // CRS=... is WMS 1.3.0 161 // 162 // The difference: 163 // For SRS x is east-west and y is north-south 164 // For CRS x and y are as specified by the EPSG 165 // E.g. [1] lists lat as first coordinate axis and lot as second, so it is switched for EPSG:4326. 166 // For most other EPSG code there seems to be no difference. 167 // CHECKSTYLE.OFF: LineLength 168 // [1] https://www.epsg-registry.org/report.htm?type=selection&entity=urn:ogc:def:crs:EPSG::4326&reportDetail=short&style=urn:uuid:report-style:default-with-code&style_name=OGP%20Default%20With%20Code&title=EPSG:4326 169 // CHECKSTYLE.ON: LineLength 170 boolean switchLatLon = false; 171 if (baseUrl.toLowerCase(Locale.US).contains("crs=epsg:4326")) { 172 switchLatLon = true; 173 } else if (baseUrl.toLowerCase(Locale.US).contains("crs=")) { 174 // assume WMS 1.3.0 175 switchLatLon = Main.getProjection().switchXY(); 176 } 177 String bbox; 178 if (switchLatLon) { 179 bbox = String.format("%s,%s,%s,%s", latLonFormat.format(s), latLonFormat.format(w), latLonFormat.format(n), latLonFormat.format(e)); 180 } else { 181 bbox = String.format("%s,%s,%s,%s", latLonFormat.format(w), latLonFormat.format(s), latLonFormat.format(e), latLonFormat.format(n)); 182 } 183 184 // Using StringBuffer and generic PATTERN_PARAM matcher gives 2x performance improvement over replaceAll 185 StringBuffer url = new StringBuffer(baseUrl.length()); 186 Matcher matcher = PATTERN_PARAM.matcher(baseUrl); 187 while (matcher.find()) { 188 String replacement; 189 switch (matcher.group(1)) { 190 case "proj": 191 replacement = myProjCode; 192 break; 193 case "bbox": 194 replacement = bbox; 195 break; 196 case "w": 197 replacement = latLonFormat.format(w); 198 break; 199 case "s": 200 replacement = latLonFormat.format(s); 201 break; 202 case "e": 203 replacement = latLonFormat.format(e); 204 break; 205 case "n": 206 replacement = latLonFormat.format(n); 207 break; 208 case "width": 209 case "height": 210 replacement = String.valueOf(getTileSize()); 211 break; 212 default: 213 replacement = '{' + matcher.group(1) + '}'; 214 } 215 matcher.appendReplacement(url, replacement); 216 } 217 matcher.appendTail(url); 218 return url.toString().replace(" ", "%20"); 219 } 220 221 @Override 222 public String getTileId(int zoom, int tilex, int tiley) { 223 return getTileUrl(zoom, tilex, tiley); 224 } 225 226 @Override 227 public ICoordinate tileXYToLatLon(Tile tile) { 228 return tileXYToLatLon(tile.getXtile(), tile.getYtile(), tile.getZoom()); 229 } 230 231 @Override 232 public ICoordinate tileXYToLatLon(TileXY xy, int zoom) { 233 return tileXYToLatLon(xy.getXIndex(), xy.getYIndex(), zoom); 234 } 235 236 @Override 237 public ICoordinate tileXYToLatLon(int x, int y, int zoom) { 238 return Main.getProjection().eastNorth2latlon(getTileEastNorth(x, y, zoom)).toCoordinate(); 239 } 240 241 @Override 242 public TileXY latLonToTileXY(double lat, double lon, int zoom) { 243 Projection proj = Main.getProjection(); 244 EastNorth enPoint = proj.latlon2eastNorth(new LatLon(lat, lon)); 245 double scale = getDegreesPerTile(zoom); 246 return new TileXY( 247 (enPoint.east() - topLeftCorner.east()) / scale, 248 (topLeftCorner.north() - enPoint.north()) / scale 249 ); 250 } 251 252 @Override 253 public TileXY latLonToTileXY(ICoordinate point, int zoom) { 254 return latLonToTileXY(point.getLat(), point.getLon(), zoom); 255 } 256 257 @Override 258 public int getTileXMax(int zoom) { 259 return tileXMax[zoom]; 260 } 261 262 @Override 263 public int getTileXMin(int zoom) { 264 return 0; 265 } 266 267 @Override 268 public int getTileYMax(int zoom) { 269 return tileYMax[zoom]; 270 } 271 272 @Override 273 public int getTileYMin(int zoom) { 274 return 0; 275 } 276 277 @Override 278 public Point latLonToXY(double lat, double lon, int zoom) { 279 double scale = getDegreesPerTile(zoom) / getTileSize(); 280 EastNorth point = Main.getProjection().latlon2eastNorth(new LatLon(lat, lon)); 281 return new Point( 282 (int) Math.round((point.east() - topLeftCorner.east()) / scale), 283 (int) Math.round((topLeftCorner.north() - point.north()) / scale) 284 ); 285 } 286 287 @Override 288 public Point latLonToXY(ICoordinate point, int zoom) { 289 return latLonToXY(point.getLat(), point.getLon(), zoom); 290 } 291 292 @Override 293 public ICoordinate xyToLatLon(Point point, int zoom) { 294 return xyToLatLon(point.x, point.y, zoom); 295 } 296 297 @Override 298 public ICoordinate xyToLatLon(int x, int y, int zoom) { 299 double scale = getDegreesPerTile(zoom) / getTileSize(); 300 Projection proj = Main.getProjection(); 301 EastNorth ret = new EastNorth( 302 topLeftCorner.east() + x * scale, 303 topLeftCorner.north() - y * scale 304 ); 305 return proj.eastNorth2latlon(ret).toCoordinate(); 306 } 307 308 @Override 309 public Map<String, String> getHeaders() { 310 return headers; 311 } 312 313 /** 314 * Checks if url is acceptable by this Tile Source 315 * @param url URL to check 316 */ 317 public static void checkUrl(String url) { 318 CheckParameterUtil.ensureParameterNotNull(url, "url"); 319 Matcher m = PATTERN_PARAM.matcher(url); 320 while (m.find()) { 321 boolean isSupportedPattern = false; 322 for (Pattern pattern : ALL_PATTERNS) { 323 if (pattern.matcher(m.group()).matches()) { 324 isSupportedPattern = true; 325 break; 326 } 327 } 328 if (!isSupportedPattern) { 329 throw new IllegalArgumentException( 330 tr("{0} is not a valid WMS argument. Please check this server URL:\n{1}", m.group(), url)); 331 } 332 } 333 } 334 335 private void handleTemplate() { 336 // Capturing group pattern on switch values 337 StringBuffer output = new StringBuffer(); 338 Matcher matcher = PATTERN_HEADER.matcher(this.baseUrl); 339 while (matcher.find()) { 340 headers.put(matcher.group(1), matcher.group(2)); 341 matcher.appendReplacement(output, ""); 342 } 343 matcher.appendTail(output); 344 this.baseUrl = output.toString(); 345 } 346 347 protected EastNorth getTileEastNorth(int x, int y, int z) { 348 double scale = getDegreesPerTile(z); 349 return new EastNorth( 350 topLeftCorner.east() + x * scale, 351 topLeftCorner.north() - y * scale 352 ); 353 } 354 355 private double getDegreesPerTile(int zoom) { 356 return degreesPerTile[zoom]; 357 } 358 359 /** 360 * returns world bounds, but detect situation, when default bounds are provided (-90, -180, 90, 180), and projection 361 * returns very close values for both min and max X. To work around this problem, cap this projection on north and south 362 * pole, the same way they are capped in Mercator projection, so conversions should work properly 363 */ 364 private static Bounds getWorldBounds() { 365 Projection proj = Main.getProjection(); 366 Bounds bounds = proj.getWorldBoundsLatLon(); 367 EastNorth min = proj.latlon2eastNorth(bounds.getMin()); 368 EastNorth max = proj.latlon2eastNorth(bounds.getMax()); 369 370 if (Math.abs(min.getX() - max.getX()) < 1 && bounds.equals(new Bounds(new LatLon(-90, -180), new LatLon(90, 180)))) { 371 return new Bounds( 372 new LatLon(OsmMercator.MIN_LAT, bounds.getMinLon()), 373 new LatLon(OsmMercator.MAX_LAT, bounds.getMaxLon()) 374 ); 375 } 376 return bounds; 377 } 378}