001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.imagery; 003 004import static javax.xml.stream.XMLStreamConstants.END_ELEMENT; 005import static javax.xml.stream.XMLStreamConstants.START_ELEMENT; 006import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_DCP; 007import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_GET; 008import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_HTTP; 009import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_IDENTIFIER; 010import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_LOWER_CORNER; 011import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_OPERATION; 012import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_OPERATIONS_METADATA; 013import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_SUPPORTED_CRS; 014import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_TITLE; 015import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_UPPER_CORNER; 016import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_WGS84_BOUNDING_BOX; 017import static org.openstreetmap.josm.tools.I18n.tr; 018 019import java.awt.Point; 020import java.io.ByteArrayInputStream; 021import java.io.IOException; 022import java.io.InputStream; 023import java.nio.charset.StandardCharsets; 024import java.nio.file.InvalidPathException; 025import java.util.ArrayList; 026import java.util.Arrays; 027import java.util.Collection; 028import java.util.Collections; 029import java.util.Deque; 030import java.util.LinkedHashSet; 031import java.util.LinkedList; 032import java.util.List; 033import java.util.Map; 034import java.util.Map.Entry; 035import java.util.Objects; 036import java.util.Optional; 037import java.util.SortedSet; 038import java.util.TreeSet; 039import java.util.concurrent.ConcurrentHashMap; 040import java.util.function.BiFunction; 041import java.util.stream.Collectors; 042 043import javax.imageio.ImageIO; 044import javax.swing.ListSelectionModel; 045import javax.xml.namespace.QName; 046import javax.xml.stream.XMLStreamException; 047import javax.xml.stream.XMLStreamReader; 048 049import org.openstreetmap.gui.jmapviewer.Coordinate; 050import org.openstreetmap.gui.jmapviewer.Projected; 051import org.openstreetmap.gui.jmapviewer.Tile; 052import org.openstreetmap.gui.jmapviewer.TileRange; 053import org.openstreetmap.gui.jmapviewer.TileXY; 054import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate; 055import org.openstreetmap.gui.jmapviewer.interfaces.IProjected; 056import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource; 057import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource; 058import org.openstreetmap.josm.data.ProjectionBounds; 059import org.openstreetmap.josm.data.coor.EastNorth; 060import org.openstreetmap.josm.data.coor.LatLon; 061import org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.TransferMode; 062import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType; 063import org.openstreetmap.josm.data.osm.BBox; 064import org.openstreetmap.josm.data.projection.Projection; 065import org.openstreetmap.josm.data.projection.ProjectionRegistry; 066import org.openstreetmap.josm.data.projection.Projections; 067import org.openstreetmap.josm.gui.ExtendedDialog; 068import org.openstreetmap.josm.gui.MainApplication; 069import org.openstreetmap.josm.gui.layer.NativeScaleLayer.ScaleList; 070import org.openstreetmap.josm.gui.layer.imagery.WMTSLayerSelection; 071import org.openstreetmap.josm.io.CachedFile; 072import org.openstreetmap.josm.spi.preferences.Config; 073import org.openstreetmap.josm.tools.CheckParameterUtil; 074import org.openstreetmap.josm.tools.Logging; 075import org.openstreetmap.josm.tools.Utils; 076 077/** 078 * Tile Source handling WMTS providers 079 * 080 * @author Wiktor Niesiobędzki 081 * @since 8526 082 */ 083public class WMTSTileSource extends AbstractTMSTileSource implements TemplatedTileSource { 084 /** 085 * WMTS namespace address 086 */ 087 public static final String WMTS_NS_URL = "http://www.opengis.net/wmts/1.0"; 088 089 // CHECKSTYLE.OFF: SingleSpaceSeparator 090 private static final QName QN_CONTENTS = new QName(WMTS_NS_URL, "Contents"); 091 private static final QName QN_DEFAULT = new QName(WMTS_NS_URL, "Default"); 092 private static final QName QN_DIMENSION = new QName(WMTS_NS_URL, "Dimension"); 093 private static final QName QN_FORMAT = new QName(WMTS_NS_URL, "Format"); 094 private static final QName QN_LAYER = new QName(WMTS_NS_URL, "Layer"); 095 private static final QName QN_MATRIX_WIDTH = new QName(WMTS_NS_URL, "MatrixWidth"); 096 private static final QName QN_MATRIX_HEIGHT = new QName(WMTS_NS_URL, "MatrixHeight"); 097 private static final QName QN_RESOURCE_URL = new QName(WMTS_NS_URL, "ResourceURL"); 098 private static final QName QN_SCALE_DENOMINATOR = new QName(WMTS_NS_URL, "ScaleDenominator"); 099 private static final QName QN_STYLE = new QName(WMTS_NS_URL, "Style"); 100 private static final QName QN_TILEMATRIX = new QName(WMTS_NS_URL, "TileMatrix"); 101 private static final QName QN_TILEMATRIXSET = new QName(WMTS_NS_URL, "TileMatrixSet"); 102 private static final QName QN_TILEMATRIX_SET_LINK = new QName(WMTS_NS_URL, "TileMatrixSetLink"); 103 private static final QName QN_TILE_WIDTH = new QName(WMTS_NS_URL, "TileWidth"); 104 private static final QName QN_TILE_HEIGHT = new QName(WMTS_NS_URL, "TileHeight"); 105 private static final QName QN_TOPLEFT_CORNER = new QName(WMTS_NS_URL, "TopLeftCorner"); 106 private static final QName QN_VALUE = new QName(WMTS_NS_URL, "Value"); 107 // CHECKSTYLE.ON: SingleSpaceSeparator 108 109 private static final String URL_GET_ENCODING_PARAMS = "SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER={layer}&STYLE={style}&" 110 + "FORMAT={format}&tileMatrixSet={TileMatrixSet}&tileMatrix={TileMatrix}&tileRow={TileRow}&tileCol={TileCol}"; 111 112 private int cachedTileSize = -1; 113 114 private static class TileMatrix { 115 private String identifier; 116 private double scaleDenominator; 117 private EastNorth topLeftCorner; 118 private int tileWidth; 119 private int tileHeight; 120 private int matrixWidth = -1; 121 private int matrixHeight = -1; 122 } 123 124 private static class TileMatrixSetBuilder { 125 // sorted by zoom level 126 SortedSet<TileMatrix> tileMatrix = new TreeSet<>((o1, o2) -> -1 * Double.compare(o1.scaleDenominator, o2.scaleDenominator)); 127 private String crs; 128 private String identifier; 129 130 TileMatrixSet build() { 131 return new TileMatrixSet(this); 132 } 133 } 134 135 /** 136 * class representing WMTS TileMatrixSet 137 * This connects projection and TileMatrix (how the map is divided in tiles) 138 * @since 13733 139 */ 140 public static class TileMatrixSet { 141 142 private final List<TileMatrix> tileMatrix; 143 private final String crs; 144 private final String identifier; 145 146 TileMatrixSet(TileMatrixSet tileMatrixSet) { 147 if (tileMatrixSet != null) { 148 tileMatrix = new ArrayList<>(tileMatrixSet.tileMatrix); 149 crs = tileMatrixSet.crs; 150 identifier = tileMatrixSet.identifier; 151 } else { 152 tileMatrix = Collections.emptyList(); 153 crs = null; 154 identifier = null; 155 } 156 } 157 158 TileMatrixSet(TileMatrixSetBuilder builder) { 159 tileMatrix = new ArrayList<>(builder.tileMatrix); 160 crs = builder.crs; 161 identifier = builder.identifier; 162 } 163 164 @Override 165 public String toString() { 166 return "TileMatrixSet [crs=" + crs + ", identifier=" + identifier + ']'; 167 } 168 169 /** 170 * Returns identifier of this TileMatrixSet. 171 * @return identifier of this TileMatrixSet 172 */ 173 public String getIdentifier() { 174 return identifier; 175 } 176 177 /** 178 * Returns projection of this tileMatrix. 179 * @return projection of this tileMatrix 180 */ 181 public String getCrs() { 182 return crs; 183 } 184 185 /** 186 * Returns tile matrix max zoom. Assumes first zoom starts at 0, with continuous zoom levels. 187 * @return tile matrix max zoom 188 * @since 15409 189 */ 190 public int getMaxZoom() { 191 return tileMatrix.size() - 1; 192 } 193 } 194 195 private static class Dimension { 196 private String identifier; 197 private String defaultValue; 198 private final List<String> values = new ArrayList<>(); 199 } 200 201 /** 202 * Class representing WMTS Layer information 203 * @since 13733 204 */ 205 public static class Layer { 206 private String format; 207 private String identifier; 208 private String title; 209 private TileMatrixSet tileMatrixSet; 210 private String baseUrl; 211 private String style; 212 private BBox bbox; 213 private final Collection<String> tileMatrixSetLinks = new ArrayList<>(); 214 private final Collection<Dimension> dimensions = new ArrayList<>(); 215 216 Layer(Layer l) { 217 Objects.requireNonNull(l); 218 format = l.format; 219 identifier = l.identifier; 220 title = l.title; 221 baseUrl = l.baseUrl; 222 style = l.style; 223 bbox = l.bbox; 224 tileMatrixSet = new TileMatrixSet(l.tileMatrixSet); 225 dimensions.addAll(l.dimensions); 226 } 227 228 Layer() { 229 } 230 231 /** 232 * Get title of the layer for user display. 233 * 234 * This is either the content of the Title element (if available) or 235 * the layer identifier (as fallback) 236 * @return title of the layer for user display 237 */ 238 public String getUserTitle() { 239 return title != null ? title : identifier; 240 } 241 242 @Override 243 public String toString() { 244 return "Layer [identifier=" + identifier + ", title=" + title + ", tileMatrixSet=" 245 + tileMatrixSet + ", baseUrl=" + baseUrl + ", style=" + style + ']'; 246 } 247 248 /** 249 * Returns identifier of this layer. 250 * @return identifier of this layer 251 */ 252 public String getIdentifier() { 253 return identifier; 254 } 255 256 /** 257 * Returns style of this layer. 258 * @return style of this layer 259 */ 260 public String getStyle() { 261 return style; 262 } 263 264 /** 265 * Returns tileMatrixSet of this layer. 266 * @return tileMatrixSet of this layer 267 */ 268 public TileMatrixSet getTileMatrixSet() { 269 return tileMatrixSet; 270 } 271 272 /** 273 * Returns layer max zoom. 274 * @return layer max zoom 275 * @since 15409 276 */ 277 public int getMaxZoom() { 278 return tileMatrixSet != null ? tileMatrixSet.getMaxZoom() : 0; 279 } 280 281 /** 282 * Returns the WGS84 bounding box. 283 * @return WGS84 bounding box 284 * @since 15410 285 */ 286 public BBox getBbox() { 287 return bbox; 288 } 289 } 290 291 /** 292 * Exception thrown when parser doesn't find expected information in GetCapabilities document 293 * @since 13733 294 */ 295 public static class WMTSGetCapabilitiesException extends Exception { 296 297 /** 298 * Create WMTS exception 299 * @param cause description of cause 300 */ 301 public WMTSGetCapabilitiesException(String cause) { 302 super(cause); 303 } 304 305 /** 306 * Create WMTS exception 307 * @param cause description of cause 308 * @param t nested exception 309 */ 310 public WMTSGetCapabilitiesException(String cause, Throwable t) { 311 super(cause, t); 312 } 313 } 314 315 private static final class SelectLayerDialog extends ExtendedDialog { 316 private final WMTSLayerSelection list; 317 318 SelectLayerDialog(Collection<Layer> layers) { 319 super(MainApplication.getMainFrame(), tr("Select WMTS layer"), tr("Add layers"), tr("Cancel")); 320 this.list = new WMTSLayerSelection(groupLayersByNameAndTileMatrixSet(layers)); 321 setContent(list); 322 } 323 324 @Override 325 public void setupDialog() { 326 super.setupDialog(); 327 buttons.get(0).setEnabled(false); 328 ListSelectionModel selectionModel = list.getTable().getSelectionModel(); 329 selectionModel.addListSelectionListener(e -> buttons.get(0).setEnabled(!selectionModel.isSelectionEmpty())); 330 } 331 332 public DefaultLayer getSelectedLayer() { 333 Layer selectedLayer = list.getSelectedLayer(); 334 return selectedLayer == null ? null : 335 new DefaultLayer(ImageryType.WMTS, selectedLayer.identifier, selectedLayer.style, selectedLayer.tileMatrixSet.identifier); 336 } 337 } 338 339 private final Map<String, String> headers = new ConcurrentHashMap<>(); 340 private final Collection<Layer> layers; 341 private Layer currentLayer; 342 private TileMatrixSet currentTileMatrixSet; 343 private double crsScale; 344 private final TransferMode transferMode; 345 346 private ScaleList nativeScaleList; 347 348 private final DefaultLayer defaultLayer; 349 350 private Projection tileProjection; 351 352 /** 353 * Creates a tile source based on imagery info 354 * @param info imagery info 355 * @throws IOException if any I/O error occurs 356 * @throws WMTSGetCapabilitiesException when document didn't contain any layers 357 * @throws IllegalArgumentException if any other error happens for the given imagery info 358 */ 359 public WMTSTileSource(ImageryInfo info) throws IOException, WMTSGetCapabilitiesException { 360 super(info); 361 CheckParameterUtil.ensureThat(info.getDefaultLayers().size() < 2, "At most 1 default layer for WMTS is supported"); 362 this.headers.putAll(info.getCustomHttpHeaders()); 363 this.baseUrl = GetCapabilitiesParseHelper.normalizeCapabilitiesUrl(ImageryPatterns.handleHeaderTemplate(info.getUrl(), headers)); 364 WMTSCapabilities capabilities = getCapabilities(baseUrl, headers); 365 this.layers = capabilities.getLayers(); 366 this.baseUrl = capabilities.getBaseUrl(); 367 this.transferMode = capabilities.getTransferMode(); 368 if (info.getDefaultLayers().isEmpty()) { 369 Logging.warn(tr("No default layer selected, choosing first layer.")); 370 if (!layers.isEmpty()) { 371 Layer first = layers.iterator().next(); 372 // If max zoom lower than expected, try to find a better layer 373 final int maxZoom = info.getMaxZoom(); 374 if (first.getMaxZoom() < maxZoom) { 375 first = layers.stream().filter(l -> l.getMaxZoom() >= maxZoom).findFirst().orElse(first); 376 } 377 // If center of josm bbox not in layer bbox, try to find a better layer 378 if (info.getBounds() != null && first.getBbox() != null) { 379 LatLon center = info.getBounds().getCenter(); 380 if (!first.getBbox().bounds(center)) { 381 final Layer ffirst = first; 382 first = layers.stream() 383 .filter(l -> l.getMaxZoom() >= maxZoom && l.getBbox() != null && l.getBbox().bounds(center)).findFirst() 384 .orElseGet(() -> layers.stream().filter(l -> l.getBbox() != null && l.getBbox().bounds(center)).findFirst() 385 .orElse(ffirst)); 386 } 387 } 388 this.defaultLayer = new DefaultLayer(info.getImageryType(), first.identifier, first.style, first.tileMatrixSet.identifier); 389 } else { 390 this.defaultLayer = null; 391 } 392 } else { 393 this.defaultLayer = info.getDefaultLayers().get(0); 394 } 395 if (this.layers.isEmpty()) 396 throw new IllegalArgumentException(tr("No layers defined by getCapabilities document: {0}", info.getUrl())); 397 } 398 399 /** 400 * Creates a tile source based on imagery info and initializes it with given projection. 401 * @param info imagery info 402 * @param projection projection to be used by this TileSource 403 * @throws IOException if any I/O error occurs 404 * @throws WMTSGetCapabilitiesException when document didn't contain any layers 405 * @throws IllegalArgumentException if any other error happens for the given imagery info 406 * @since 14507 407 */ 408 public WMTSTileSource(ImageryInfo info, Projection projection) throws IOException, WMTSGetCapabilitiesException { 409 this(info); 410 initProjection(projection); 411 } 412 413 /** 414 * Creates a dialog based on this tile source with all available layers and returns the name of selected layer 415 * @return Name of selected layer 416 */ 417 public DefaultLayer userSelectLayer() { 418 Map<String, List<Layer>> layerById = layers.stream().collect( 419 Collectors.groupingBy(x -> x.identifier)); 420 if (layerById.size() == 1) { // only one layer 421 List<Layer> ls = layerById.entrySet().iterator().next().getValue() 422 .stream().filter( 423 u -> u.tileMatrixSet.crs.equals(ProjectionRegistry.getProjection().toCode())) 424 .collect(Collectors.toList()); 425 if (ls.size() == 1) { 426 // only one tile matrix set with matching projection - no point in asking 427 Layer selectedLayer = ls.get(0); 428 return new DefaultLayer(ImageryType.WMTS, selectedLayer.identifier, selectedLayer.style, selectedLayer.tileMatrixSet.identifier); 429 } 430 } 431 432 final SelectLayerDialog layerSelection = new SelectLayerDialog(layers); 433 if (layerSelection.showDialog().getValue() == 1) { 434 return layerSelection.getSelectedLayer(); 435 } 436 return null; 437 } 438 439 /** 440 * Call remote server and parse response to WMTSCapabilities object 441 * 442 * @param url of the getCapabilities document 443 * @param headers HTTP headers to set when calling getCapabilities url 444 * @return capabilities 445 * @throws IOException in case of any I/O error 446 * @throws WMTSGetCapabilitiesException when document didn't contain any layers 447 * @throws IllegalArgumentException in case of any other error 448 */ 449 public static WMTSCapabilities getCapabilities(String url, Map<String, String> headers) throws IOException, WMTSGetCapabilitiesException { 450 try (CachedFile cf = new CachedFile(url); InputStream in = cf.setHttpHeaders(headers). 451 setMaxAge(Config.getPref().getLong("wmts.capabilities.cache.max_age", 7 * CachedFile.DAYS)). 452 setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince). 453 getInputStream()) { 454 byte[] data = Utils.readBytesFromStream(in); 455 if (data.length == 0) { 456 cf.clear(); 457 throw new IllegalArgumentException("Could not read data from: " + url); 458 } 459 460 try { 461 XMLStreamReader reader = GetCapabilitiesParseHelper.getReader(new ByteArrayInputStream(data)); 462 WMTSCapabilities ret = null; 463 Collection<Layer> layers = null; 464 for (int event = reader.getEventType(); reader.hasNext(); event = reader.next()) { 465 if (event == START_ELEMENT) { 466 QName qName = reader.getName(); 467 if (QN_OWS_OPERATIONS_METADATA.equals(qName)) { 468 ret = parseOperationMetadata(reader); 469 } else if (QN_CONTENTS.equals(qName)) { 470 layers = parseContents(reader); 471 } 472 } 473 } 474 if (ret == null) { 475 /* 476 * see #12168 - create dummy operation metadata - not all WMTS services provide this information 477 * 478 * WMTS Standard: 479 * > Resource oriented architecture style HTTP encodings SHALL not be described in the OperationsMetadata section. 480 * 481 * And OperationMetada is not mandatory element. So REST mode is justifiable 482 */ 483 ret = new WMTSCapabilities(url, TransferMode.REST); 484 } 485 if (layers == null) { 486 throw new WMTSGetCapabilitiesException(tr("WMTS Capabilities document did not contain layers in url: {0}", url)); 487 } 488 ret.addLayers(layers); 489 return ret; 490 } catch (XMLStreamException e) { 491 cf.clear(); 492 Logging.warn(new String(data, StandardCharsets.UTF_8)); 493 throw new WMTSGetCapabilitiesException(tr("Error during parsing of WMTS Capabilities document: {0}", e.getMessage()), e); 494 } 495 } catch (InvalidPathException e) { 496 throw new WMTSGetCapabilitiesException(tr("Invalid path for GetCapabilities document: {0}", e.getMessage()), e); 497 } 498 } 499 500 /** 501 * Parse Contents tag. Returns when reader reaches Contents closing tag 502 * 503 * @param reader StAX reader instance 504 * @return collection of layers within contents with properly linked TileMatrixSets 505 * @throws XMLStreamException See {@link XMLStreamReader} 506 */ 507 private static Collection<Layer> parseContents(XMLStreamReader reader) throws XMLStreamException { 508 Map<String, TileMatrixSet> matrixSetById = new ConcurrentHashMap<>(); 509 Collection<Layer> layers = new ArrayList<>(); 510 for (int event = reader.getEventType(); 511 reader.hasNext() && !(event == END_ELEMENT && QN_CONTENTS.equals(reader.getName())); 512 event = reader.next()) { 513 if (event == START_ELEMENT) { 514 QName qName = reader.getName(); 515 if (QN_LAYER.equals(qName)) { 516 Layer l = parseLayer(reader); 517 if (l != null) { 518 layers.add(l); 519 } 520 } else if (QN_TILEMATRIXSET.equals(qName)) { 521 TileMatrixSet entry = parseTileMatrixSet(reader); 522 matrixSetById.put(entry.identifier, entry); 523 } 524 } 525 } 526 Collection<Layer> ret = new ArrayList<>(); 527 // link layers to matrix sets 528 for (Layer l: layers) { 529 for (String tileMatrixId: l.tileMatrixSetLinks) { 530 Layer newLayer = new Layer(l); // create a new layer object for each tile matrix set supported 531 newLayer.tileMatrixSet = matrixSetById.get(tileMatrixId); 532 ret.add(newLayer); 533 } 534 } 535 return ret; 536 } 537 538 /** 539 * Parse Layer tag. Returns when reader will reach Layer closing tag 540 * 541 * @param reader StAX reader instance 542 * @return Layer object, with tileMatrixSetLinks and no tileMatrixSet attribute set. 543 * @throws XMLStreamException See {@link XMLStreamReader} 544 */ 545 private static Layer parseLayer(XMLStreamReader reader) throws XMLStreamException { 546 Layer layer = new Layer(); 547 Deque<QName> tagStack = new LinkedList<>(); 548 List<String> supportedMimeTypes = new ArrayList<>(Arrays.asList(ImageIO.getReaderMIMETypes())); 549 supportedMimeTypes.add("image/jpgpng"); // used by ESRI 550 supportedMimeTypes.add("image/png8"); // used by geoserver 551 if (supportedMimeTypes.contains("image/jpeg")) { 552 supportedMimeTypes.add("image/jpg"); // sometimes misspelled by Arcgis 553 } 554 Collection<String> unsupportedFormats = new ArrayList<>(); 555 556 for (int event = reader.getEventType(); 557 reader.hasNext() && !(event == END_ELEMENT && QN_LAYER.equals(reader.getName())); 558 event = reader.next()) { 559 if (event == START_ELEMENT) { 560 QName qName = reader.getName(); 561 tagStack.push(qName); 562 if (tagStack.size() == 2) { 563 if (QN_FORMAT.equals(qName)) { 564 String format = reader.getElementText(); 565 if (supportedMimeTypes.contains(format)) { 566 layer.format = format; 567 } else { 568 unsupportedFormats.add(format); 569 } 570 } else if (QN_OWS_IDENTIFIER.equals(qName)) { 571 layer.identifier = reader.getElementText(); 572 } else if (QN_OWS_TITLE.equals(qName)) { 573 layer.title = reader.getElementText(); 574 } else if (QN_RESOURCE_URL.equals(qName) && 575 "tile".equals(reader.getAttributeValue("", "resourceType"))) { 576 layer.baseUrl = reader.getAttributeValue("", "template"); 577 } else if (QN_STYLE.equals(qName) && 578 "true".equals(reader.getAttributeValue("", "isDefault"))) { 579 if (GetCapabilitiesParseHelper.moveReaderToTag(reader, QN_OWS_IDENTIFIER)) { 580 layer.style = reader.getElementText(); 581 tagStack.push(reader.getName()); // keep tagStack in sync 582 } 583 } else if (QN_DIMENSION.equals(qName)) { 584 layer.dimensions.add(parseDimension(reader)); 585 } else if (QN_TILEMATRIX_SET_LINK.equals(qName)) { 586 layer.tileMatrixSetLinks.add(parseTileMatrixSetLink(reader)); 587 } else if (QN_OWS_WGS84_BOUNDING_BOX.equals(qName)) { 588 layer.bbox = parseBoundingBox(reader); 589 } else { 590 GetCapabilitiesParseHelper.moveReaderToEndCurrentTag(reader); 591 } 592 } 593 } 594 // need to get event type from reader, as parsing might have change position of reader 595 if (reader.getEventType() == END_ELEMENT) { 596 QName start = tagStack.pop(); 597 if (!start.equals(reader.getName())) { 598 throw new IllegalStateException(tr("WMTS Parser error - start element {0} has different name than end element {2}", 599 start, reader.getName())); 600 } 601 } 602 } 603 if (layer.style == null) { 604 layer.style = ""; 605 } 606 if (layer.format == null) { 607 // no format found - it's mandatory parameter - can't use this layer 608 Logging.warn(tr("Can''t use layer {0} because no supported formats where found. Layer is available in formats: {1}", 609 layer.getUserTitle(), 610 String.join(", ", unsupportedFormats))); 611 return null; 612 } 613 return layer; 614 } 615 616 /** 617 * Gets Dimension value. Returns when reader is on Dimension closing tag 618 * 619 * @param reader StAX reader instance 620 * @return dimension 621 * @throws XMLStreamException See {@link XMLStreamReader} 622 */ 623 private static Dimension parseDimension(XMLStreamReader reader) throws XMLStreamException { 624 Dimension ret = new Dimension(); 625 for (int event = reader.getEventType(); 626 reader.hasNext() && !(event == END_ELEMENT && QN_DIMENSION.equals(reader.getName())); 627 event = reader.next()) { 628 if (event == START_ELEMENT) { 629 QName qName = reader.getName(); 630 if (QN_OWS_IDENTIFIER.equals(qName)) { 631 ret.identifier = reader.getElementText(); 632 } else if (QN_DEFAULT.equals(qName)) { 633 ret.defaultValue = reader.getElementText(); 634 } else if (QN_VALUE.equals(qName)) { 635 ret.values.add(reader.getElementText()); 636 } 637 } 638 } 639 return ret; 640 } 641 642 /** 643 * Gets TileMatrixSetLink value. Returns when reader is on TileMatrixSetLink closing tag 644 * 645 * @param reader StAX reader instance 646 * @return TileMatrixSetLink identifier 647 * @throws XMLStreamException See {@link XMLStreamReader} 648 */ 649 private static String parseTileMatrixSetLink(XMLStreamReader reader) throws XMLStreamException { 650 String ret = null; 651 for (int event = reader.getEventType(); 652 reader.hasNext() && !(event == END_ELEMENT && QN_TILEMATRIX_SET_LINK.equals(reader.getName())); 653 event = reader.next()) { 654 if (event == START_ELEMENT && QN_TILEMATRIXSET.equals(reader.getName())) { 655 ret = reader.getElementText(); 656 } 657 } 658 return ret; 659 } 660 661 /** 662 * Parses TileMatrixSet section. Returns when reader is on TileMatrixSet closing tag 663 * @param reader StAX reader instance 664 * @return TileMatrixSet object 665 * @throws XMLStreamException See {@link XMLStreamReader} 666 */ 667 private static TileMatrixSet parseTileMatrixSet(XMLStreamReader reader) throws XMLStreamException { 668 TileMatrixSetBuilder matrixSet = new TileMatrixSetBuilder(); 669 for (int event = reader.getEventType(); 670 reader.hasNext() && !(event == END_ELEMENT && QN_TILEMATRIXSET.equals(reader.getName())); 671 event = reader.next()) { 672 if (event == START_ELEMENT) { 673 QName qName = reader.getName(); 674 if (QN_OWS_IDENTIFIER.equals(qName)) { 675 matrixSet.identifier = reader.getElementText(); 676 } else if (QN_OWS_SUPPORTED_CRS.equals(qName)) { 677 matrixSet.crs = GetCapabilitiesParseHelper.crsToCode(reader.getElementText()); 678 } else if (QN_TILEMATRIX.equals(qName)) { 679 matrixSet.tileMatrix.add(parseTileMatrix(reader, matrixSet.crs)); 680 } 681 } 682 } 683 return matrixSet.build(); 684 } 685 686 /** 687 * Parses TileMatrix section. Returns when reader is on TileMatrix closing tag. 688 * @param reader StAX reader instance 689 * @param matrixCrs projection used by this matrix 690 * @return TileMatrix object 691 * @throws XMLStreamException See {@link XMLStreamReader} 692 */ 693 private static TileMatrix parseTileMatrix(XMLStreamReader reader, String matrixCrs) throws XMLStreamException { 694 Projection matrixProj = Optional.ofNullable(Projections.getProjectionByCode(matrixCrs)) 695 .orElseGet(ProjectionRegistry::getProjection); // use current projection if none found. Maybe user is using custom string 696 TileMatrix ret = new TileMatrix(); 697 for (int event = reader.getEventType(); 698 reader.hasNext() && !(event == END_ELEMENT && QN_TILEMATRIX.equals(reader.getName())); 699 event = reader.next()) { 700 if (event == START_ELEMENT) { 701 QName qName = reader.getName(); 702 if (QN_OWS_IDENTIFIER.equals(qName)) { 703 ret.identifier = reader.getElementText(); 704 } else if (QN_SCALE_DENOMINATOR.equals(qName)) { 705 ret.scaleDenominator = Double.parseDouble(reader.getElementText()); 706 } else if (QN_TOPLEFT_CORNER.equals(qName)) { 707 ret.topLeftCorner = parseEastNorth(reader.getElementText(), matrixProj.switchXY()); 708 } else if (QN_TILE_HEIGHT.equals(qName)) { 709 ret.tileHeight = Integer.parseInt(reader.getElementText()); 710 } else if (QN_TILE_WIDTH.equals(qName)) { 711 ret.tileWidth = Integer.parseInt(reader.getElementText()); 712 } else if (QN_MATRIX_HEIGHT.equals(qName)) { 713 ret.matrixHeight = Integer.parseInt(reader.getElementText()); 714 } else if (QN_MATRIX_WIDTH.equals(qName)) { 715 ret.matrixWidth = Integer.parseInt(reader.getElementText()); 716 } 717 } 718 } 719 if (ret.tileHeight != ret.tileWidth) { 720 throw new AssertionError(tr("Only square tiles are supported. {0}x{1} returned by server for TileMatrix identifier {2}", 721 ret.tileHeight, ret.tileWidth, ret.identifier)); 722 } 723 return ret; 724 } 725 726 private static <T> T parseCoor(String coor, boolean switchXY, BiFunction<String, String, T> function) { 727 String[] parts = coor.split(" ", -1); 728 if (switchXY) { 729 return function.apply(parts[1], parts[0]); 730 } else { 731 return function.apply(parts[0], parts[1]); 732 } 733 } 734 735 private static EastNorth parseEastNorth(String coor, boolean switchXY) { 736 return parseCoor(coor, switchXY, (e, n) -> new EastNorth(Double.parseDouble(e), Double.parseDouble(n))); 737 } 738 739 private static LatLon parseLatLon(String coor, boolean switchXY) { 740 return parseCoor(coor, switchXY, (lon, lat) -> new LatLon(Double.parseDouble(lat), Double.parseDouble(lon))); 741 } 742 743 /** 744 * Parses WGS84BoundingBox section. Returns when reader is on WGS84BoundingBox closing tag. 745 * @param reader StAX reader instance 746 * @return WGS84 bounding box 747 * @throws XMLStreamException See {@link XMLStreamReader} 748 */ 749 private static BBox parseBoundingBox(XMLStreamReader reader) throws XMLStreamException { 750 LatLon lowerCorner = null; 751 LatLon upperCorner = null; 752 for (int event = reader.getEventType(); 753 reader.hasNext() && !(event == END_ELEMENT && QN_OWS_WGS84_BOUNDING_BOX.equals(reader.getName())); 754 event = reader.next()) { 755 if (event == START_ELEMENT) { 756 QName qName = reader.getName(); 757 if (QN_OWS_LOWER_CORNER.equals(qName)) { 758 lowerCorner = parseLatLon(reader.getElementText(), false); 759 } else if (QN_OWS_UPPER_CORNER.equals(qName)) { 760 upperCorner = parseLatLon(reader.getElementText(), false); 761 } 762 } 763 } 764 if (lowerCorner != null && upperCorner != null) { 765 return new BBox(lowerCorner, upperCorner); 766 } 767 return null; 768 } 769 770 /** 771 * Parses OperationMetadata section. Returns when reader is on OperationsMetadata closing tag. 772 * return WMTSCapabilities with baseUrl and transferMode 773 * 774 * @param reader StAX reader instance 775 * @return WMTSCapabilities with baseUrl and transferMode set 776 * @throws XMLStreamException See {@link XMLStreamReader} 777 */ 778 private static WMTSCapabilities parseOperationMetadata(XMLStreamReader reader) throws XMLStreamException { 779 for (int event = reader.getEventType(); 780 reader.hasNext() && !(event == END_ELEMENT && QN_OWS_OPERATIONS_METADATA.equals(reader.getName())); 781 event = reader.next()) { 782 if (event == START_ELEMENT && 783 QN_OWS_OPERATION.equals(reader.getName()) && 784 "GetTile".equals(reader.getAttributeValue("", "name")) && 785 GetCapabilitiesParseHelper.moveReaderToTag(reader, QN_OWS_DCP, QN_OWS_HTTP, QN_OWS_GET)) { 786 return new WMTSCapabilities( 787 reader.getAttributeValue(GetCapabilitiesParseHelper.XLINK_NS_URL, "href"), 788 GetCapabilitiesParseHelper.getTransferMode(reader) 789 ); 790 } 791 } 792 return null; 793 } 794 795 /** 796 * Initializes projection for this TileSource with projection 797 * @param proj projection to be used by this TileSource 798 */ 799 public void initProjection(Projection proj) { 800 if (proj.equals(tileProjection)) 801 return; 802 List<Layer> matchingLayers = layers.stream().filter( 803 l -> l.identifier.equals(defaultLayer.getLayerName()) && l.tileMatrixSet.crs.equals(proj.toCode())) 804 .collect(Collectors.toList()); 805 if (matchingLayers.size() > 1) { 806 this.currentLayer = matchingLayers.stream().filter( 807 l -> l.tileMatrixSet.identifier.equals(defaultLayer.getTileMatrixSet())) 808 .findFirst().orElse(matchingLayers.get(0)); 809 this.tileProjection = proj; 810 } else if (matchingLayers.size() == 1) { 811 this.currentLayer = matchingLayers.get(0); 812 this.tileProjection = proj; 813 } else { 814 // no tile matrix sets with current projection 815 if (this.currentLayer == null) { 816 this.tileProjection = null; 817 for (Layer layer : layers) { 818 if (!layer.identifier.equals(defaultLayer.getLayerName())) { 819 continue; 820 } 821 Projection pr = Projections.getProjectionByCode(layer.tileMatrixSet.crs); 822 if (pr != null) { 823 this.currentLayer = layer; 824 this.tileProjection = pr; 825 break; 826 } 827 } 828 if (this.currentLayer == null) 829 throw new IllegalArgumentException( 830 layers.stream().map(l -> l.tileMatrixSet).collect(Collectors.toList()).toString()); 831 } // else: keep currentLayer and tileProjection as is 832 } 833 if (this.currentLayer != null) { 834 this.currentTileMatrixSet = this.currentLayer.tileMatrixSet; 835 Collection<Double> scales = currentTileMatrixSet.tileMatrix.stream() 836 .map(tileMatrix -> tileMatrix.scaleDenominator * 0.28e-03) 837 .collect(Collectors.toList()); 838 this.nativeScaleList = new ScaleList(scales); 839 } 840 this.crsScale = getTileSize() * 0.28e-03 / this.tileProjection.getMetersPerUnit(); 841 } 842 843 @Override 844 public int getTileSize() { 845 if (cachedTileSize > 0) { 846 return cachedTileSize; 847 } 848 if (currentTileMatrixSet != null) { 849 // no support for non-square tiles (tileHeight != tileWidth) 850 // and for different tile sizes at different zoom levels 851 cachedTileSize = currentTileMatrixSet.tileMatrix.get(0).tileHeight; 852 return cachedTileSize; 853 } 854 // Fallback to default mercator tile size. Maybe it will work 855 Logging.warn("WMTS: Could not determine tile size. Using default tile size of: {0}", getDefaultTileSize()); 856 return getDefaultTileSize(); 857 } 858 859 @Override 860 public String getTileUrl(int zoom, int tilex, int tiley) { 861 if (currentLayer == null) { 862 return ""; 863 } 864 865 String url; 866 if (currentLayer.baseUrl != null && transferMode == null) { 867 url = currentLayer.baseUrl; 868 } else { 869 switch (transferMode) { 870 case KVP: 871 url = baseUrl + URL_GET_ENCODING_PARAMS; 872 break; 873 case REST: 874 url = currentLayer.baseUrl; 875 break; 876 default: 877 url = ""; 878 break; 879 } 880 } 881 882 TileMatrix tileMatrix = getTileMatrix(zoom); 883 884 if (tileMatrix == null) { 885 return ""; // no matrix, probably unsupported CRS selected. 886 } 887 888 url = url.replace("{layer}", this.currentLayer.identifier) 889 .replace("{format}", this.currentLayer.format) 890 .replace("{TileMatrixSet}", this.currentTileMatrixSet.identifier) 891 .replace("{TileMatrix}", tileMatrix.identifier) 892 .replace("{TileRow}", Integer.toString(tiley)) 893 .replace("{TileCol}", Integer.toString(tilex)) 894 .replaceAll("(?i)\\{style\\}", this.currentLayer.style); 895 896 for (Dimension d : currentLayer.dimensions) { 897 url = url.replaceAll("(?i)\\{"+d.identifier+"\\}", d.defaultValue); 898 } 899 900 return url; 901 } 902 903 /** 904 * Returns TileMatrix that's working on given zoom level 905 * @param zoom zoom level 906 * @return TileMatrix that's working on this zoom level 907 */ 908 private TileMatrix getTileMatrix(int zoom) { 909 if (zoom > getMaxZoom()) { 910 return null; 911 } 912 if (zoom < 0) { 913 return null; 914 } 915 return this.currentTileMatrixSet.tileMatrix.get(zoom); 916 } 917 918 @Override 919 public double getDistance(double lat1, double lon1, double lat2, double lon2) { 920 throw new UnsupportedOperationException("Not implemented"); 921 } 922 923 @Override 924 public ICoordinate tileXYToLatLon(Tile tile) { 925 return tileXYToLatLon(tile.getXtile(), tile.getYtile(), tile.getZoom()); 926 } 927 928 @Override 929 public ICoordinate tileXYToLatLon(TileXY xy, int zoom) { 930 return tileXYToLatLon(xy.getXIndex(), xy.getYIndex(), zoom); 931 } 932 933 @Override 934 public ICoordinate tileXYToLatLon(int x, int y, int zoom) { 935 TileMatrix matrix = getTileMatrix(zoom); 936 if (matrix == null) { 937 return CoordinateConversion.llToCoor(tileProjection.getWorldBoundsLatLon().getCenter()); 938 } 939 double scale = matrix.scaleDenominator * this.crsScale; 940 EastNorth ret = new EastNorth(matrix.topLeftCorner.east() + x * scale, matrix.topLeftCorner.north() - y * scale); 941 return CoordinateConversion.llToCoor(tileProjection.eastNorth2latlon(ret)); 942 } 943 944 @Override 945 public TileXY latLonToTileXY(double lat, double lon, int zoom) { 946 TileMatrix matrix = getTileMatrix(zoom); 947 if (matrix == null) { 948 return new TileXY(0, 0); 949 } 950 951 EastNorth enPoint = tileProjection.latlon2eastNorth(new LatLon(lat, lon)); 952 double scale = matrix.scaleDenominator * this.crsScale; 953 return new TileXY( 954 (enPoint.east() - matrix.topLeftCorner.east()) / scale, 955 (matrix.topLeftCorner.north() - enPoint.north()) / scale 956 ); 957 } 958 959 @Override 960 public TileXY latLonToTileXY(ICoordinate point, int zoom) { 961 return latLonToTileXY(point.getLat(), point.getLon(), zoom); 962 } 963 964 @Override 965 public int getTileXMax(int zoom) { 966 return getTileXMax(zoom, tileProjection); 967 } 968 969 @Override 970 public int getTileYMax(int zoom) { 971 return getTileYMax(zoom, tileProjection); 972 } 973 974 @Override 975 public Point latLonToXY(double lat, double lon, int zoom) { 976 TileMatrix matrix = getTileMatrix(zoom); 977 if (matrix == null) { 978 return new Point(0, 0); 979 } 980 double scale = matrix.scaleDenominator * this.crsScale; 981 EastNorth point = tileProjection.latlon2eastNorth(new LatLon(lat, lon)); 982 return new Point( 983 (int) Math.round((point.east() - matrix.topLeftCorner.east()) / scale), 984 (int) Math.round((matrix.topLeftCorner.north() - point.north()) / scale) 985 ); 986 } 987 988 @Override 989 public Point latLonToXY(ICoordinate point, int zoom) { 990 return latLonToXY(point.getLat(), point.getLon(), zoom); 991 } 992 993 @Override 994 public Coordinate xyToLatLon(Point point, int zoom) { 995 return xyToLatLon(point.x, point.y, zoom); 996 } 997 998 @Override 999 public Coordinate xyToLatLon(int x, int y, int zoom) { 1000 TileMatrix matrix = getTileMatrix(zoom); 1001 if (matrix == null) { 1002 return new Coordinate(0, 0); 1003 } 1004 double scale = matrix.scaleDenominator * this.crsScale; 1005 EastNorth ret = new EastNorth( 1006 matrix.topLeftCorner.east() + x * scale, 1007 matrix.topLeftCorner.north() - y * scale 1008 ); 1009 LatLon ll = tileProjection.eastNorth2latlon(ret); 1010 return new Coordinate(ll.lat(), ll.lon()); 1011 } 1012 1013 @Override 1014 public Map<String, String> getHeaders() { 1015 return headers; 1016 } 1017 1018 @Override 1019 public int getMaxZoom() { 1020 if (this.currentTileMatrixSet != null) { 1021 return this.currentTileMatrixSet.getMaxZoom(); 1022 } 1023 return 0; 1024 } 1025 1026 @Override 1027 public String getTileId(int zoom, int tilex, int tiley) { 1028 return getTileUrl(zoom, tilex, tiley); 1029 } 1030 1031 /** 1032 * Checks if url is acceptable by this Tile Source 1033 * @param url URL to check 1034 */ 1035 public static void checkUrl(String url) { 1036 ImageryPatterns.checkWmtsUrlPatterns(url); 1037 } 1038 1039 /** 1040 * Group layers by name and tile matrix set. 1041 * @param layers to be grouped 1042 * @return list with entries - grouping identifier + list of layers 1043 */ 1044 public static List<Entry<String, List<Layer>>> groupLayersByNameAndTileMatrixSet(Collection<Layer> layers) { 1045 Map<String, List<Layer>> layerByName = layers.stream().collect( 1046 Collectors.groupingBy(x -> x.identifier + '\u001c' + x.tileMatrixSet.identifier)); 1047 return layerByName.entrySet().stream().sorted(Map.Entry.comparingByKey()).collect(Collectors.toList()); 1048 } 1049 1050 /** 1051 * Returns set of projection codes that this TileSource supports. 1052 * @return set of projection codes that this TileSource supports 1053 */ 1054 public Collection<String> getSupportedProjections() { 1055 return this.layers.stream() 1056 .filter(layer -> currentLayer == null || currentLayer.identifier.equals(layer.identifier)) 1057 .map(layer -> layer.tileMatrixSet.crs) 1058 .collect(Collectors.toCollection(LinkedHashSet::new)); 1059 } 1060 1061 private int getTileYMax(int zoom, Projection proj) { 1062 TileMatrix matrix = getTileMatrix(zoom); 1063 if (matrix == null) { 1064 return 0; 1065 } 1066 1067 if (matrix.matrixHeight != -1) { 1068 return matrix.matrixHeight; 1069 } 1070 1071 double scale = matrix.scaleDenominator * this.crsScale; 1072 EastNorth min = matrix.topLeftCorner; 1073 EastNorth max = proj.latlon2eastNorth(proj.getWorldBoundsLatLon().getMax()); 1074 return (int) Math.ceil(Math.abs(max.north() - min.north()) / scale); 1075 } 1076 1077 private int getTileXMax(int zoom, Projection proj) { 1078 TileMatrix matrix = getTileMatrix(zoom); 1079 if (matrix == null) { 1080 return 0; 1081 } 1082 if (matrix.matrixWidth != -1) { 1083 return matrix.matrixWidth; 1084 } 1085 1086 double scale = matrix.scaleDenominator * this.crsScale; 1087 EastNorth min = matrix.topLeftCorner; 1088 EastNorth max = proj.latlon2eastNorth(proj.getWorldBoundsLatLon().getMax()); 1089 return (int) Math.ceil(Math.abs(max.east() - min.east()) / scale); 1090 } 1091 1092 /** 1093 * Get native scales of tile source. 1094 * @return {@link ScaleList} of native scales 1095 */ 1096 public ScaleList getNativeScales() { 1097 return nativeScaleList; 1098 } 1099 1100 /** 1101 * Returns the tile projection. 1102 * @return the tile projection 1103 */ 1104 public Projection getTileProjection() { 1105 return tileProjection; 1106 } 1107 1108 @Override 1109 public IProjected tileXYtoProjected(int x, int y, int zoom) { 1110 TileMatrix matrix = getTileMatrix(zoom); 1111 if (matrix == null) { 1112 return new Projected(0, 0); 1113 } 1114 double scale = matrix.scaleDenominator * this.crsScale; 1115 return new Projected( 1116 matrix.topLeftCorner.east() + x * scale, 1117 matrix.topLeftCorner.north() - y * scale); 1118 } 1119 1120 @Override 1121 public TileXY projectedToTileXY(IProjected projected, int zoom) { 1122 TileMatrix matrix = getTileMatrix(zoom); 1123 if (matrix == null) { 1124 return new TileXY(0, 0); 1125 } 1126 double scale = matrix.scaleDenominator * this.crsScale; 1127 return new TileXY( 1128 (projected.getEast() - matrix.topLeftCorner.east()) / scale, 1129 -(projected.getNorth() - matrix.topLeftCorner.north()) / scale); 1130 } 1131 1132 private EastNorth tileToEastNorth(int x, int y, int z) { 1133 return CoordinateConversion.projToEn(this.tileXYtoProjected(x, y, z)); 1134 } 1135 1136 private ProjectionBounds getTileProjectionBounds(Tile tile) { 1137 ProjectionBounds pb = new ProjectionBounds(tileToEastNorth(tile.getXtile(), tile.getYtile(), tile.getZoom())); 1138 pb.extend(tileToEastNorth(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom())); 1139 return pb; 1140 } 1141 1142 @Override 1143 public boolean isInside(Tile inner, Tile outer) { 1144 ProjectionBounds pbInner = getTileProjectionBounds(inner); 1145 ProjectionBounds pbOuter = getTileProjectionBounds(outer); 1146 // a little tolerance, for when inner tile touches the border of the outer tile 1147 double epsilon = 1e-7 * (pbOuter.maxEast - pbOuter.minEast); 1148 return pbOuter.minEast <= pbInner.minEast + epsilon && 1149 pbOuter.minNorth <= pbInner.minNorth + epsilon && 1150 pbOuter.maxEast >= pbInner.maxEast - epsilon && 1151 pbOuter.maxNorth >= pbInner.maxNorth - epsilon; 1152 } 1153 1154 @Override 1155 public TileRange getCoveringTileRange(Tile tile, int newZoom) { 1156 TileMatrix matrixNew = getTileMatrix(newZoom); 1157 if (matrixNew == null) { 1158 return new TileRange(new TileXY(0, 0), new TileXY(0, 0), newZoom); 1159 } 1160 IProjected p0 = tileXYtoProjected(tile.getXtile(), tile.getYtile(), tile.getZoom()); 1161 IProjected p1 = tileXYtoProjected(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom()); 1162 TileXY tMin = projectedToTileXY(p0, newZoom); 1163 TileXY tMax = projectedToTileXY(p1, newZoom); 1164 // shrink the target tile a little, so we don't get neighboring tiles, that 1165 // share an edge, but don't actually cover the target tile 1166 double epsilon = 1e-7 * (tMax.getX() - tMin.getX()); 1167 int minX = (int) Math.floor(tMin.getX() + epsilon); 1168 int minY = (int) Math.floor(tMin.getY() + epsilon); 1169 int maxX = (int) Math.ceil(tMax.getX() - epsilon) - 1; 1170 int maxY = (int) Math.ceil(tMax.getY() - epsilon) - 1; 1171 return new TileRange(new TileXY(minX, minY), new TileXY(maxX, maxY), newZoom); 1172 } 1173 1174 @Override 1175 public String getServerCRS() { 1176 return tileProjection != null ? tileProjection.toCode() : null; 1177 } 1178 1179 /** 1180 * Layers that can be used with this tile source 1181 * @return unmodifiable collection of layers available in this tile source 1182 * @since 13879 1183 */ 1184 public Collection<Layer> getLayers() { 1185 return Collections.unmodifiableCollection(layers); 1186 } 1187}