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}