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