source: josm/trunk/src/org/openstreetmap/josm/data/imagery/WMTSTileSource.java

Last change on this file was 18911, checked in by taylor.smock, 4 months ago

Fix #23113: Use default methods from JMapViewer

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