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