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_BBOX = Pattern.compile("\\{bbox\\}"); 051 private static final Pattern PATTERN_W = Pattern.compile("\\{w\\}"); 052 private static final Pattern PATTERN_S = Pattern.compile("\\{s\\}"); 053 private static final Pattern PATTERN_E = Pattern.compile("\\{e\\}"); 054 private static final Pattern PATTERN_N = Pattern.compile("\\{n\\}"); 055 private static final Pattern PATTERN_WIDTH = Pattern.compile("\\{width\\}"); 056 private static final Pattern PATTERN_HEIGHT = Pattern.compile("\\{height\\}"); 057 private static final Pattern PATTERN_PARAM = Pattern.compile("\\{([^}]+)\\}"); 058 059 private static final NumberFormat latLonFormat = new DecimalFormat("###0.0000000", new DecimalFormatSymbols(Locale.US)); 060 061 private static final Pattern[] ALL_PATTERNS = { 062 PATTERN_HEADER, PATTERN_PROJ, PATTERN_BBOX, PATTERN_W, PATTERN_S, PATTERN_E, PATTERN_N, PATTERN_WIDTH, PATTERN_HEIGHT 063 }; 064 065 /* 066 * Constant taken from OGC WMTS Implementation Specification (http://www.opengeospatial.org/standards/wmts) 067 * From table E.4 - Definition of Well-known scale set GoogleMapsCompatibile 068 * 069 * As higher zoom levels have denominator divided by 2, we keep only zoom level 1 in the code 070 */ 071 private static final float SCALE_DENOMINATOR_ZOOM_LEVEL_1 = 559082264.0287178f; 072 073 /** 074 * Creates a tile source based on imagery info 075 * @param info imagery info 076 */ 077 public TemplatedWMSTileSource(ImageryInfo info) { 078 super(info); 079 this.serverProjections = new TreeSet<>(info.getServerProjections()); 080 handleTemplate(); 081 initProjection(); 082 // FIXME: remove in September 2015, when ImageryPreferenceEntry.tileSize will be initialized to -1 instead to 256 083 // need to leave it as it is to keep compatibility between tested and latest JOSM versions 084 tileSize = WMSLayer.PROP_IMAGE_SIZE.get(); 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(2, zoom - 1)) * 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 "bbox": 206 replacement = bbox; 207 break; 208 case "w": 209 replacement = latLonFormat.format(w); 210 break; 211 case "s": 212 replacement = latLonFormat.format(s); 213 break; 214 case "e": 215 replacement = latLonFormat.format(e); 216 break; 217 case "n": 218 replacement = latLonFormat.format(n); 219 break; 220 case "width": 221 case "height": 222 replacement = String.valueOf(getTileSize()); 223 break; 224 default: 225 replacement = '{' + matcher.group(1) + '}'; 226 } 227 matcher.appendReplacement(url, replacement); 228 } 229 matcher.appendTail(url); 230 return url.toString().replace(" ", "%20"); 231 } 232 233 @Override 234 public String getTileId(int zoom, int tilex, int tiley) { 235 return getTileUrl(zoom, tilex, tiley); 236 } 237 238 @Override 239 public ICoordinate tileXYToLatLon(Tile tile) { 240 return tileXYToLatLon(tile.getXtile(), tile.getYtile(), tile.getZoom()); 241 } 242 243 @Override 244 public ICoordinate tileXYToLatLon(TileXY xy, int zoom) { 245 return tileXYToLatLon(xy.getXIndex(), xy.getYIndex(), zoom); 246 } 247 248 @Override 249 public ICoordinate tileXYToLatLon(int x, int y, int zoom) { 250 return Main.getProjection().eastNorth2latlon(getTileEastNorth(x, y, zoom)).toCoordinate(); 251 } 252 253 @Override 254 public TileXY latLonToTileXY(double lat, double lon, int zoom) { 255 Projection proj = Main.getProjection(); 256 EastNorth enPoint = proj.latlon2eastNorth(new LatLon(lat, lon)); 257 return eastNorthToTileXY(enPoint, zoom); 258 } 259 260 private TileXY eastNorthToTileXY(EastNorth enPoint, int zoom) { 261 double scale = getDegreesPerTile(zoom); 262 return new TileXY( 263 (enPoint.east() - anchorPosition.east()) / scale, 264 (anchorPosition.north() - enPoint.north()) / scale 265 ); 266 } 267 268 @Override 269 public TileXY latLonToTileXY(ICoordinate point, int zoom) { 270 return latLonToTileXY(point.getLat(), point.getLon(), zoom); 271 } 272 273 @Override 274 public int getTileXMax(int zoom) { 275 return tileXMax[zoom]; 276 } 277 278 @Override 279 public int getTileXMin(int zoom) { 280 return tileXMin[zoom]; 281 } 282 283 @Override 284 public int getTileYMax(int zoom) { 285 return tileYMax[zoom]; 286 } 287 288 @Override 289 public int getTileYMin(int zoom) { 290 return tileYMin[zoom]; 291 } 292 293 @Override 294 public Point latLonToXY(double lat, double lon, int zoom) { 295 double scale = getDegreesPerTile(zoom) / getTileSize(); 296 EastNorth point = Main.getProjection().latlon2eastNorth(new LatLon(lat, lon)); 297 return new Point( 298 (int) Math.round((point.east() - anchorPosition.east()) / scale), 299 (int) Math.round((anchorPosition.north() - point.north()) / scale) 300 ); 301 } 302 303 @Override 304 public Point latLonToXY(ICoordinate point, int zoom) { 305 return latLonToXY(point.getLat(), point.getLon(), zoom); 306 } 307 308 @Override 309 public ICoordinate xyToLatLon(Point point, int zoom) { 310 return xyToLatLon(point.x, point.y, zoom); 311 } 312 313 @Override 314 public ICoordinate xyToLatLon(int x, int y, int zoom) { 315 double scale = getDegreesPerTile(zoom) / getTileSize(); 316 Projection proj = Main.getProjection(); 317 EastNorth ret = new EastNorth( 318 anchorPosition.east() + x * scale, 319 anchorPosition.north() - y * scale 320 ); 321 return proj.eastNorth2latlon(ret).toCoordinate(); 322 } 323 324 @Override 325 public Map<String, String> getHeaders() { 326 return headers; 327 } 328 329 /** 330 * Checks if url is acceptable by this Tile Source 331 * @param url URL to check 332 */ 333 public static void checkUrl(String url) { 334 CheckParameterUtil.ensureParameterNotNull(url, "url"); 335 Matcher m = PATTERN_PARAM.matcher(url); 336 while (m.find()) { 337 boolean isSupportedPattern = false; 338 for (Pattern pattern : ALL_PATTERNS) { 339 if (pattern.matcher(m.group()).matches()) { 340 isSupportedPattern = true; 341 break; 342 } 343 } 344 if (!isSupportedPattern) { 345 throw new IllegalArgumentException( 346 tr("{0} is not a valid WMS argument. Please check this server URL:\n{1}", m.group(), url)); 347 } 348 } 349 } 350 351 private void handleTemplate() { 352 // Capturing group pattern on switch values 353 StringBuffer output = new StringBuffer(); 354 Matcher matcher = PATTERN_HEADER.matcher(this.baseUrl); 355 while (matcher.find()) { 356 headers.put(matcher.group(1), matcher.group(2)); 357 matcher.appendReplacement(output, ""); 358 } 359 matcher.appendTail(output); 360 this.baseUrl = output.toString(); 361 } 362 363 protected EastNorth getTileEastNorth(int x, int y, int z) { 364 double scale = getDegreesPerTile(z); 365 return new EastNorth( 366 anchorPosition.east() + x * scale, 367 anchorPosition.north() - y * scale 368 ); 369 } 370 371 private double getDegreesPerTile(int zoom) { 372 return degreesPerTile[zoom]; 373 } 374}