001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Color;
007import java.awt.Component;
008import java.awt.Dimension;
009import java.awt.Font;
010import java.awt.Graphics;
011import java.awt.Graphics2D;
012import java.awt.GridBagLayout;
013import java.awt.Image;
014import java.awt.Point;
015import java.awt.Shape;
016import java.awt.Toolkit;
017import java.awt.event.ActionEvent;
018import java.awt.event.MouseAdapter;
019import java.awt.event.MouseEvent;
020import java.awt.geom.AffineTransform;
021import java.awt.geom.Point2D;
022import java.awt.geom.Rectangle2D;
023import java.awt.image.BufferedImage;
024import java.awt.image.ImageObserver;
025import java.io.File;
026import java.io.IOException;
027import java.net.MalformedURLException;
028import java.net.URL;
029import java.text.SimpleDateFormat;
030import java.util.ArrayList;
031import java.util.Arrays;
032import java.util.Collection;
033import java.util.Collections;
034import java.util.Comparator;
035import java.util.Date;
036import java.util.LinkedList;
037import java.util.List;
038import java.util.Map;
039import java.util.Map.Entry;
040import java.util.Objects;
041import java.util.Set;
042import java.util.TreeSet;
043import java.util.concurrent.ConcurrentSkipListSet;
044import java.util.concurrent.atomic.AtomicInteger;
045import java.util.function.Consumer;
046import java.util.function.Function;
047import java.util.stream.Collectors;
048import java.util.stream.IntStream;
049import java.util.stream.Stream;
050
051import javax.swing.AbstractAction;
052import javax.swing.Action;
053import javax.swing.JLabel;
054import javax.swing.JMenu;
055import javax.swing.JMenuItem;
056import javax.swing.JOptionPane;
057import javax.swing.JPanel;
058import javax.swing.JPopupMenu;
059import javax.swing.JSeparator;
060import javax.swing.Timer;
061
062import org.openstreetmap.gui.jmapviewer.AttributionSupport;
063import org.openstreetmap.gui.jmapviewer.MemoryTileCache;
064import org.openstreetmap.gui.jmapviewer.OsmTileLoader;
065import org.openstreetmap.gui.jmapviewer.Tile;
066import org.openstreetmap.gui.jmapviewer.TileRange;
067import org.openstreetmap.gui.jmapviewer.TileXY;
068import org.openstreetmap.gui.jmapviewer.interfaces.CachedTileLoader;
069import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
070import org.openstreetmap.gui.jmapviewer.interfaces.IProjected;
071import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource;
072import org.openstreetmap.gui.jmapviewer.interfaces.TileCache;
073import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
074import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
075import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
076import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource;
077import org.openstreetmap.josm.actions.ExpertToggleAction;
078import org.openstreetmap.josm.actions.ImageryAdjustAction;
079import org.openstreetmap.josm.actions.RenameLayerAction;
080import org.openstreetmap.josm.actions.SaveActionBase;
081import org.openstreetmap.josm.data.Bounds;
082import org.openstreetmap.josm.data.ProjectionBounds;
083import org.openstreetmap.josm.data.coor.EastNorth;
084import org.openstreetmap.josm.data.coor.LatLon;
085import org.openstreetmap.josm.data.imagery.CoordinateConversion;
086import org.openstreetmap.josm.data.imagery.ImageryInfo;
087import org.openstreetmap.josm.data.imagery.OffsetBookmark;
088import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
089import org.openstreetmap.josm.data.imagery.TileLoaderFactory;
090import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
091import org.openstreetmap.josm.data.preferences.IntegerProperty;
092import org.openstreetmap.josm.data.projection.Projection;
093import org.openstreetmap.josm.data.projection.ProjectionRegistry;
094import org.openstreetmap.josm.data.projection.Projections;
095import org.openstreetmap.josm.gui.ExtendedDialog;
096import org.openstreetmap.josm.gui.MainApplication;
097import org.openstreetmap.josm.gui.MapView;
098import org.openstreetmap.josm.gui.NavigatableComponent.ZoomChangeListener;
099import org.openstreetmap.josm.gui.Notification;
100import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
101import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
102import org.openstreetmap.josm.gui.io.importexport.WMSLayerImporter;
103import org.openstreetmap.josm.gui.layer.imagery.AutoLoadTilesAction;
104import org.openstreetmap.josm.gui.layer.imagery.AutoZoomAction;
105import org.openstreetmap.josm.gui.layer.imagery.DecreaseZoomAction;
106import org.openstreetmap.josm.gui.layer.imagery.FlushTileCacheAction;
107import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings.FilterChangeListener;
108import org.openstreetmap.josm.gui.layer.imagery.IncreaseZoomAction;
109import org.openstreetmap.josm.gui.layer.imagery.LoadAllTilesAction;
110import org.openstreetmap.josm.gui.layer.imagery.LoadErroneousTilesAction;
111import org.openstreetmap.josm.gui.layer.imagery.ReprojectionTile;
112import org.openstreetmap.josm.gui.layer.imagery.ShowErrorsAction;
113import org.openstreetmap.josm.gui.layer.imagery.TileAnchor;
114import org.openstreetmap.josm.gui.layer.imagery.TileCoordinateConverter;
115import org.openstreetmap.josm.gui.layer.imagery.TilePosition;
116import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings;
117import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings.DisplaySettingsChangeEvent;
118import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings.DisplaySettingsChangeListener;
119import org.openstreetmap.josm.gui.layer.imagery.ZoomToBestAction;
120import org.openstreetmap.josm.gui.layer.imagery.ZoomToNativeLevelAction;
121import org.openstreetmap.josm.gui.progress.ProgressMonitor;
122import org.openstreetmap.josm.gui.util.GuiHelper;
123import org.openstreetmap.josm.tools.GBC;
124import org.openstreetmap.josm.tools.HttpClient;
125import org.openstreetmap.josm.tools.Logging;
126import org.openstreetmap.josm.tools.MemoryManager;
127import org.openstreetmap.josm.tools.MemoryManager.MemoryHandle;
128import org.openstreetmap.josm.tools.MemoryManager.NotEnoughMemoryException;
129import org.openstreetmap.josm.tools.Utils;
130import org.openstreetmap.josm.tools.bugreport.BugReport;
131
132/**
133 * Base abstract class that supports displaying images provided by TileSource. It might be TMS source, WMS or WMTS
134 * It implements all standard functions of tilesource based layers: autozoom, tile reloads, layer saving, loading,etc.
135 *
136 * @author Upliner
137 * @author Wiktor Niesiobędzki
138 * @param <T> Tile Source class used for this layer
139 * @since 3715
140 * @since 8526 (copied from TMSLayer)
141 */
142public abstract class AbstractTileSourceLayer<T extends AbstractTMSTileSource> extends ImageryLayer
143implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeListener, DisplaySettingsChangeListener {
144    private static final String PREFERENCE_PREFIX = "imagery.generic";
145    static { // Registers all setting properties
146        new TileSourceDisplaySettings();
147    }
148
149    /** maximum zoom level supported */
150    public static final int MAX_ZOOM = 30;
151    /** minium zoom level supported */
152    public static final int MIN_ZOOM = 2;
153    private static final Font InfoFont = new Font("sansserif", Font.BOLD, 13);
154
155    /** additional layer menu actions */
156    private static List<MenuAddition> menuAdditions = new LinkedList<>();
157
158    /** minimum zoom level to show to user */
159    public static final IntegerProperty PROP_MIN_ZOOM_LVL = new IntegerProperty(PREFERENCE_PREFIX + ".min_zoom_lvl", 2);
160    /** maximum zoom level to show to user */
161    public static final IntegerProperty PROP_MAX_ZOOM_LVL = new IntegerProperty(PREFERENCE_PREFIX + ".max_zoom_lvl", 20);
162
163    //public static final BooleanProperty PROP_DRAW_DEBUG = new BooleanProperty(PREFERENCE_PREFIX + ".draw_debug", false);
164    /** Zoomlevel at which tiles is currently downloaded. Initial zoom lvl is set to bestZoom */
165    private int currentZoomLevel;
166
167    private final AttributionSupport attribution = new AttributionSupport();
168
169    /**
170     * Offset between calculated zoom level and zoom level used to download and show tiles. Negative values will result in
171     * lower resolution of imagery useful in "retina" displays, positive values will result in higher resolution
172     */
173    public static final IntegerProperty ZOOM_OFFSET = new IntegerProperty(PREFERENCE_PREFIX + ".zoom_offset", 0);
174
175    /*
176     *  use MemoryTileCache instead of tileLoader JCS cache, as tileLoader caches only content (byte[] of image)
177     *  and MemoryTileCache caches whole Tile. This gives huge performance improvement when a lot of tiles are visible
178     *  in MapView (for example - when limiting min zoom in imagery)
179     *
180     *  Use per-layer tileCache instance, as the more layers there are, the more tiles needs to be cached
181     */
182    protected TileCache tileCache; // initialized together with tileSource
183    protected T tileSource;
184    protected TileLoader tileLoader;
185
186    /** A timer that is used to delay invalidation events if required. */
187    private final Timer invalidateLaterTimer = new Timer(100, e -> this.invalidate());
188
189    private final MouseAdapter adapter = new MouseAdapter() {
190        @Override
191        public void mouseClicked(MouseEvent e) {
192            if (!isVisible()) return;
193            if (e.getButton() == MouseEvent.BUTTON3) {
194                Component component = e.getComponent();
195                if (component.isShowing()) {
196                    new TileSourceLayerPopup(e.getX(), e.getY()).show(component, e.getX(), e.getY());
197                }
198            } else if (e.getButton() == MouseEvent.BUTTON1) {
199                attribution.handleAttribution(e.getPoint(), true);
200            }
201        }
202    };
203
204    private final TileSourceDisplaySettings displaySettings = createDisplaySettings();
205
206    private final ImageryAdjustAction adjustAction = new ImageryAdjustAction(this);
207    // prepared to be moved to the painter
208    protected TileCoordinateConverter coordinateConverter;
209    private final long minimumTileExpire;
210
211    /**
212     * Creates Tile Source based Imagery Layer based on Imagery Info
213     * @param info imagery info
214     */
215    public AbstractTileSourceLayer(ImageryInfo info) {
216        super(info);
217        setBackgroundLayer(true);
218        this.setVisible(true);
219        getFilterSettings().addFilterChangeListener(this);
220        getDisplaySettings().addSettingsChangeListener(this);
221        this.minimumTileExpire = info.getMinimumTileExpire();
222    }
223
224    /**
225     * This method creates the {@link TileSourceDisplaySettings} object. Subclasses may implement it to e.g. change the prefix.
226     * @return The object.
227     * @since 10568
228     */
229    protected TileSourceDisplaySettings createDisplaySettings() {
230        return new TileSourceDisplaySettings();
231    }
232
233    /**
234     * Gets the {@link TileSourceDisplaySettings} instance associated with this tile source.
235     * @return The tile source display settings
236     * @since 10568
237     */
238    public TileSourceDisplaySettings getDisplaySettings() {
239        return displaySettings;
240    }
241
242    @Override
243    public void filterChanged() {
244        invalidate();
245    }
246
247    protected abstract TileLoaderFactory getTileLoaderFactory();
248
249    /**
250     * Get projections this imagery layer supports natively.
251     *
252     * For example projection of tiles that are downloaded from a server. Layer
253     * may support even more projections (by reprojecting the tiles), but with a
254     * certain loss in image quality and performance.
255     * @return projections this imagery layer supports natively; null if layer is projection agnostic.
256     */
257    public abstract Collection<String> getNativeProjections();
258
259    /**
260     * Creates and returns a new {@link TileSource} instance depending on {@link #info} specified in the constructor.
261     *
262     * @return TileSource for specified ImageryInfo
263     * @throws IllegalArgumentException when Imagery is not supported by layer
264     */
265    protected abstract T getTileSource();
266
267    protected Map<String, String> getHeaders(T tileSource) {
268        if (tileSource instanceof TemplatedTileSource) {
269            return ((TemplatedTileSource) tileSource).getHeaders();
270        }
271        return null;
272    }
273
274    protected void initTileSource(T tileSource) {
275        coordinateConverter = new TileCoordinateConverter(MainApplication.getMap().mapView, tileSource, getDisplaySettings());
276        attribution.initialize(tileSource);
277
278        currentZoomLevel = getBestZoom();
279
280        Map<String, String> headers = getHeaders(tileSource);
281
282        tileLoader = getTileLoaderFactory().makeTileLoader(this, headers, minimumTileExpire);
283
284        try {
285            if ("file".equalsIgnoreCase(new URL(tileSource.getBaseUrl()).getProtocol())) {
286                tileLoader = new OsmTileLoader(this);
287            }
288        } catch (MalformedURLException e) {
289            // ignore, assume that this is not a file
290            Logging.log(Logging.LEVEL_DEBUG, e);
291        }
292
293        if (tileLoader == null)
294            tileLoader = new OsmTileLoader(this, headers);
295
296        tileCache = new MemoryTileCache(estimateTileCacheSize());
297    }
298
299    @Override
300    public synchronized void tileLoadingFinished(Tile tile, boolean success) {
301        if (tile.hasError()) {
302            success = false;
303            tile.setImage(null);
304        }
305        invalidateLater();
306        Logging.debug("tileLoadingFinished() tile: {0} success: {1}", tile, success);
307    }
308
309    /**
310     * Clears the tile cache.
311     */
312    public void clearTileCache() {
313        if (tileLoader instanceof CachedTileLoader) {
314            ((CachedTileLoader) tileLoader).clearCache(tileSource);
315        }
316        tileCache.clear();
317    }
318
319    @Override
320    public Object getInfoComponent() {
321        JPanel panel = (JPanel) super.getInfoComponent();
322        List<List<String>> content = new ArrayList<>();
323        Collection<String> nativeProjections = getNativeProjections();
324        if (nativeProjections != null) {
325            content.add(Arrays.asList(tr("Native projections"), Utils.join(", ", getNativeProjections())));
326        }
327        EastNorth offset = getDisplaySettings().getDisplacement();
328        if (offset.distanceSq(0, 0) > 1e-10) {
329            content.add(Arrays.asList(tr("Offset"), offset.east() + ";" + offset.north()));
330        }
331        if (coordinateConverter.requiresReprojection()) {
332            content.add(Arrays.asList(tr("Tile download projection"), tileSource.getServerCRS()));
333            content.add(Arrays.asList(tr("Tile display projection"), ProjectionRegistry.getProjection().toCode()));
334        }
335        content.add(Arrays.asList(tr("Current zoom"), Integer.toString(currentZoomLevel)));
336        for (List<String> entry: content) {
337            panel.add(new JLabel(entry.get(0) + ':'), GBC.std());
338            panel.add(GBC.glue(5, 0), GBC.std());
339            panel.add(createTextField(entry.get(1)), GBC.eol().fill(GBC.HORIZONTAL));
340        }
341        return panel;
342    }
343
344    @Override
345    protected Action getAdjustAction() {
346        return adjustAction;
347    }
348
349    /**
350     * Returns average number of screen pixels per tile pixel for current mapview
351     * @param zoom zoom level
352     * @return average number of screen pixels per tile pixel
353     */
354    public double getScaleFactor(int zoom) {
355        if (coordinateConverter != null) {
356            return coordinateConverter.getScaleFactor(zoom);
357        } else {
358            return 1;
359        }
360    }
361
362    /**
363     * Returns best zoom level.
364     * @return best zoom level
365     */
366    public int getBestZoom() {
367        double factor = getScaleFactor(1); // check the ratio between area of tilesize at zoom 1 to current view
368        double result = Math.log(factor)/Math.log(2)/2;
369        /*
370         * Math.log(factor)/Math.log(2) - gives log base 2 of factor
371         * We divide result by 2, as factor contains ratio between areas. We could do Math.sqrt before log, or just divide log by 2
372         *
373         * ZOOM_OFFSET controls, whether we work with overzoomed or underzoomed tiles. Positive ZOOM_OFFSET
374         * is for working with underzoomed tiles (higher quality when working with aerial imagery), negative ZOOM_OFFSET
375         * is for working with overzoomed tiles (big, pixelated), which is good when working with high-dpi screens and/or
376         * maps as a imagery layer
377         */
378        int intResult = (int) Math.round(result + 1 + ZOOM_OFFSET.get() / 1.9);
379        int minZoom = getMinZoomLvl();
380        int maxZoom = getMaxZoomLvl();
381        if (minZoom <= maxZoom) {
382            intResult = Utils.clamp(intResult, minZoom, maxZoom);
383        } else if (intResult > maxZoom) {
384            intResult = maxZoom;
385        }
386        return intResult;
387    }
388
389    /**
390     * Default implementation of {@link org.openstreetmap.josm.gui.layer.Layer.LayerAction#supportLayers(List)}.
391     * @param layers layers
392     * @return {@code true} is layers contains only a {@code TMSLayer}
393     */
394    public static boolean actionSupportLayers(List<Layer> layers) {
395        return layers.size() == 1 && layers.get(0) instanceof TMSLayer;
396    }
397
398    private abstract static class AbstractTileAction extends AbstractAction {
399
400        protected final AbstractTileSourceLayer<?> layer;
401        protected final Tile tile;
402
403        AbstractTileAction(String name, AbstractTileSourceLayer<?> layer, Tile tile) {
404            super(name);
405            this.layer = layer;
406            this.tile = tile;
407        }
408    }
409
410    private static final class ShowTileInfoAction extends AbstractTileAction {
411
412        private ShowTileInfoAction(AbstractTileSourceLayer<?> layer, Tile tile) {
413            super(tr("Show tile info"), layer, tile);
414            setEnabled(tile != null);
415        }
416
417        private static String getSizeString(int size) {
418            return new StringBuilder().append(size).append('x').append(size).toString();
419        }
420
421        @Override
422        public void actionPerformed(ActionEvent ae) {
423            if (tile != null) {
424                ExtendedDialog ed = new ExtendedDialog(MainApplication.getMainFrame(), tr("Tile Info"), tr("OK"));
425                JPanel panel = new JPanel(new GridBagLayout());
426                Rectangle2D displaySize = layer.coordinateConverter.getRectangleForTile(tile);
427                String url = "";
428                try {
429                    url = tile.getUrl();
430                } catch (IOException e) {
431                    // silence exceptions
432                    Logging.trace(e);
433                }
434
435                List<List<String>> content = new ArrayList<>();
436                content.add(Arrays.asList(tr("Tile name"), tile.getKey()));
437                content.add(Arrays.asList(tr("Tile URL"), url));
438                if (tile.getTileSource() instanceof TemplatedTileSource) {
439                    Map<String, String> headers = ((TemplatedTileSource) tile.getTileSource()).getHeaders();
440                    for (String key: new TreeSet<>(headers.keySet())) {
441                        // iterate over sorted keys
442                        content.add(Arrays.asList(tr("Custom header: {0}", key), headers.get(key)));
443                    }
444                }
445                content.add(Arrays.asList(tr("Tile size"),
446                        getSizeString(tile.getTileSource().getTileSize())));
447                content.add(Arrays.asList(tr("Tile display size"),
448                        new StringBuilder().append(displaySize.getWidth())
449                                .append('x')
450                                .append(displaySize.getHeight()).toString()));
451                if (layer.coordinateConverter.requiresReprojection()) {
452                    content.add(Arrays.asList(tr("Reprojection"),
453                            tile.getTileSource().getServerCRS() +
454                            " -> " + ProjectionRegistry.getProjection().toCode()));
455                    BufferedImage img = tile.getImage();
456                    if (img != null) {
457                        content.add(Arrays.asList(tr("Reprojected tile size"),
458                            img.getWidth() + "x" + img.getHeight()));
459
460                    }
461                }
462                content.add(Arrays.asList(tr("Status"), tr(tile.getStatus())));
463                content.add(Arrays.asList(tr("Loaded"), tr(Boolean.toString(tile.isLoaded()))));
464                content.add(Arrays.asList(tr("Loading"), tr(Boolean.toString(tile.isLoading()))));
465                content.add(Arrays.asList(tr("Error"), tr(Boolean.toString(tile.hasError()))));
466                for (List<String> entry: content) {
467                    panel.add(new JLabel(entry.get(0) + ':'), GBC.std());
468                    panel.add(GBC.glue(5, 0), GBC.std());
469                    panel.add(layer.createTextField(entry.get(1)), GBC.eol().fill(GBC.HORIZONTAL));
470                }
471
472                for (Entry<String, String> e: tile.getMetadata().entrySet()) {
473                    panel.add(new JLabel(tr("Metadata ") + tr(e.getKey()) + ':'), GBC.std());
474                    panel.add(GBC.glue(5, 0), GBC.std());
475                    String value = e.getValue();
476                    if ("lastModification".equals(e.getKey()) || "expirationTime".equals(e.getKey())) {
477                        value = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(Long.parseLong(value)));
478                    }
479                    panel.add(layer.createTextField(value), GBC.eol().fill(GBC.HORIZONTAL));
480
481                }
482                ed.setIcon(JOptionPane.INFORMATION_MESSAGE);
483                ed.setContent(panel);
484                ed.showDialog();
485            }
486        }
487    }
488
489    private static final class LoadTileAction extends AbstractTileAction {
490
491        private LoadTileAction(AbstractTileSourceLayer<?> layer, Tile tile) {
492            super(tr("Load tile"), layer, tile);
493            setEnabled(tile != null);
494        }
495
496        @Override
497        public void actionPerformed(ActionEvent ae) {
498            if (tile != null) {
499                layer.loadTile(tile, true);
500                layer.invalidate();
501            }
502        }
503    }
504
505    private static void sendOsmTileRequest(Tile tile, String request) {
506        if (tile != null) {
507            try {
508                new Notification(HttpClient.create(new URL(tile.getUrl() + '/' + request))
509                        .connect().fetchContent()).show();
510            } catch (IOException ex) {
511                Logging.error(ex);
512            }
513        }
514    }
515
516    private static final class GetOsmTileStatusAction extends AbstractTileAction {
517        private GetOsmTileStatusAction(AbstractTileSourceLayer<?> layer, Tile tile) {
518            super(tr("Get tile status"), layer, tile);
519            setEnabled(tile != null);
520        }
521
522        @Override
523        public void actionPerformed(ActionEvent e) {
524            sendOsmTileRequest(tile, "status");
525        }
526    }
527
528    private static final class MarkOsmTileDirtyAction extends AbstractTileAction {
529        private MarkOsmTileDirtyAction(AbstractTileSourceLayer<?> layer, Tile tile) {
530            super(tr("Force tile rendering"), layer, tile);
531            setEnabled(tile != null);
532        }
533
534        @Override
535        public void actionPerformed(ActionEvent e) {
536            sendOsmTileRequest(tile, "dirty");
537        }
538    }
539
540    /**
541     * Creates popup menu items and binds to mouse actions
542     */
543    @Override
544    public void hookUpMapView() {
545        // this needs to be here and not in constructor to allow empty TileSource class construction using SessionWriter
546        initializeIfRequired();
547        super.hookUpMapView();
548    }
549
550    @Override
551    public LayerPainter attachToMapView(MapViewEvent event) {
552        initializeIfRequired();
553
554        event.getMapView().addMouseListener(adapter);
555        MapView.addZoomChangeListener(this);
556
557        if (this instanceof NativeScaleLayer) {
558            event.getMapView().setNativeScaleLayer((NativeScaleLayer) this);
559        }
560
561        // FIXME: why do we need this? Without this, if you add a WMS layer and do not move the mouse, sometimes, tiles do not start loading.
562        // FIXME: Check if this is still required.
563        event.getMapView().repaint(500);
564
565        return super.attachToMapView(event);
566    }
567
568    private void initializeIfRequired() {
569        if (tileSource == null) {
570            tileSource = getTileSource();
571            if (tileSource == null) {
572                throw new IllegalArgumentException(tr("Failed to create tile source"));
573            }
574            // check if projection is supported
575            projectionChanged(null, ProjectionRegistry.getProjection());
576            initTileSource(this.tileSource);
577        }
578    }
579
580    @Override
581    protected LayerPainter createMapViewPainter(MapViewEvent event) {
582        return new TileSourcePainter();
583    }
584
585    /**
586     * Tile source layer popup menu.
587     */
588    public class TileSourceLayerPopup extends JPopupMenu {
589        /**
590         * Constructs a new {@code TileSourceLayerPopup}.
591         * @param x horizontal dimension where user clicked
592         * @param y vertical dimension where user clicked
593         */
594        public TileSourceLayerPopup(int x, int y) {
595            List<JMenu> submenus = new ArrayList<>();
596            MainApplication.getLayerManager().getVisibleLayersInZOrder().stream()
597            .filter(AbstractTileSourceLayer.class::isInstance)
598            .map(AbstractTileSourceLayer.class::cast)
599            .forEachOrdered(layer -> {
600                JMenu submenu = new JMenu(layer.getName());
601                for (Action a : layer.getCommonEntries()) {
602                    if (a instanceof LayerAction) {
603                        submenu.add(((LayerAction) a).createMenuComponent());
604                    } else {
605                        submenu.add(new JMenuItem(a));
606                    }
607                }
608                submenu.add(new JSeparator());
609                Tile tile = layer.getTileForPixelpos(x, y);
610                submenu.add(new JMenuItem(new LoadTileAction(layer, tile)));
611                submenu.add(new JMenuItem(new ShowTileInfoAction(layer, tile)));
612                if (ExpertToggleAction.isExpert() && tileSource != null && tileSource.isModTileFeatures()) {
613                    submenu.add(new JMenuItem(new GetOsmTileStatusAction(layer, tile)));
614                    submenu.add(new JMenuItem(new MarkOsmTileDirtyAction(layer, tile)));
615                }
616                submenus.add(submenu);
617            });
618
619            if (submenus.size() == 1) {
620                JMenu menu = submenus.get(0);
621                Arrays.stream(menu.getMenuComponents()).forEachOrdered(this::add);
622            } else if (submenus.size() > 1) {
623                submenus.stream().forEachOrdered(this::add);
624            }
625        }
626    }
627
628    protected int estimateTileCacheSize() {
629        Dimension screenSize = GuiHelper.getMaximumScreenSize();
630        int height = screenSize.height;
631        int width = screenSize.width;
632        int tileSize = 256; // default tile size
633        if (tileSource != null) {
634            tileSize = tileSource.getTileSize();
635        }
636        // as we can see part of the tile at the top and at the bottom, use Math.ceil(...) + 1 to accommodate for that
637        int visibileTiles = (int) (Math.ceil((double) height / tileSize + 1) * Math.ceil((double) width / tileSize + 1));
638        // add 10% for tiles from different zoom levels
639        int ret = (int) Math.ceil(
640                Math.pow(2d, ZOOM_OFFSET.get()) * visibileTiles // use offset to decide, how many tiles are visible
641                * 4);
642        Logging.info("AbstractTileSourceLayer: estimated visible tiles: {0}, estimated cache size: {1}", visibileTiles, ret);
643        return ret;
644    }
645
646    @Override
647    public void displaySettingsChanged(DisplaySettingsChangeEvent e) {
648        if (tileSource == null) {
649            return;
650        }
651        switch (e.getChangedSetting()) {
652        case TileSourceDisplaySettings.AUTO_ZOOM:
653            if (getDisplaySettings().isAutoZoom() && getBestZoom() != currentZoomLevel) {
654                setZoomLevel(getBestZoom());
655                invalidate();
656            }
657            break;
658        case TileSourceDisplaySettings.AUTO_LOAD:
659            if (getDisplaySettings().isAutoLoad()) {
660                invalidate();
661            }
662            break;
663        default:
664            // e.g. displacement
665            // trigger a redraw in every case
666            invalidate();
667        }
668    }
669
670    /**
671     * Checks zoom level against settings
672     * @param maxZoomLvl zoom level to check
673     * @param ts tile source to crosscheck with
674     * @return maximum zoom level, not higher than supported by tilesource nor set by the user
675     */
676    public static int checkMaxZoomLvl(int maxZoomLvl, TileSource ts) {
677        if (maxZoomLvl > MAX_ZOOM) {
678            maxZoomLvl = MAX_ZOOM;
679        }
680        if (maxZoomLvl < PROP_MIN_ZOOM_LVL.get()) {
681            maxZoomLvl = PROP_MIN_ZOOM_LVL.get();
682        }
683        if (ts != null && ts.getMaxZoom() != 0 && ts.getMaxZoom() < maxZoomLvl) {
684            maxZoomLvl = ts.getMaxZoom();
685        }
686        return maxZoomLvl;
687    }
688
689    /**
690     * Checks zoom level against settings
691     * @param minZoomLvl zoom level to check
692     * @param ts tile source to crosscheck with
693     * @return minimum zoom level, not higher than supported by tilesource nor set by the user
694     */
695    public static int checkMinZoomLvl(int minZoomLvl, TileSource ts) {
696        if (minZoomLvl < MIN_ZOOM) {
697            minZoomLvl = MIN_ZOOM;
698        }
699        if (minZoomLvl > PROP_MAX_ZOOM_LVL.get()) {
700            minZoomLvl = getMaxZoomLvl(ts);
701        }
702        if (ts != null && ts.getMinZoom() > minZoomLvl) {
703            minZoomLvl = ts.getMinZoom();
704        }
705        return minZoomLvl;
706    }
707
708    /**
709     * @param ts TileSource for which we want to know maximum zoom level
710     * @return maximum max zoom level, that will be shown on layer
711     */
712    public static int getMaxZoomLvl(TileSource ts) {
713        return checkMaxZoomLvl(PROP_MAX_ZOOM_LVL.get(), ts);
714    }
715
716    /**
717     * @param ts TileSource for which we want to know minimum zoom level
718     * @return minimum zoom level, that will be shown on layer
719     */
720    public static int getMinZoomLvl(TileSource ts) {
721        return checkMinZoomLvl(PROP_MIN_ZOOM_LVL.get(), ts);
722    }
723
724    /**
725     * Sets maximum zoom level, that layer will attempt show
726     * @param maxZoomLvl maximum zoom level
727     */
728    public static void setMaxZoomLvl(int maxZoomLvl) {
729        PROP_MAX_ZOOM_LVL.put(checkMaxZoomLvl(maxZoomLvl, null));
730    }
731
732    /**
733     * Sets minimum zoom level, that layer will attempt show
734     * @param minZoomLvl minimum zoom level
735     */
736    public static void setMinZoomLvl(int minZoomLvl) {
737        PROP_MIN_ZOOM_LVL.put(checkMinZoomLvl(minZoomLvl, null));
738    }
739
740    /**
741     * This fires every time the user changes the zoom, but also (due to ZoomChangeListener) - on all
742     * changes to visible map (panning/zooming)
743     */
744    @Override
745    public void zoomChanged() {
746        zoomChanged(true);
747    }
748
749    private void zoomChanged(boolean invalidate) {
750        Logging.debug("zoomChanged(): {0}", currentZoomLevel);
751        if (tileLoader instanceof TMSCachedTileLoader) {
752            ((TMSCachedTileLoader) tileLoader).cancelOutstandingTasks();
753        }
754        if (invalidate) {
755            invalidate();
756        }
757    }
758
759    protected int getMaxZoomLvl() {
760        if (info.getMaxZoom() != 0)
761            return checkMaxZoomLvl(info.getMaxZoom(), tileSource);
762        else
763            return getMaxZoomLvl(tileSource);
764    }
765
766    protected int getMinZoomLvl() {
767        if (info.getMinZoom() != 0)
768            return checkMinZoomLvl(info.getMinZoom(), tileSource);
769        else
770            return getMinZoomLvl(tileSource);
771    }
772
773    /**
774     *
775     * @return if its allowed to zoom in
776     */
777    public boolean zoomIncreaseAllowed() {
778        boolean zia = currentZoomLevel < this.getMaxZoomLvl();
779        Logging.debug("zoomIncreaseAllowed(): {0} {1} vs. {2}", zia, currentZoomLevel, this.getMaxZoomLvl());
780        return zia;
781    }
782
783    /**
784     * Zoom in, go closer to map.
785     *
786     * @return    true, if zoom increasing was successful, false otherwise
787     */
788    public boolean increaseZoomLevel() {
789        if (zoomIncreaseAllowed()) {
790            currentZoomLevel++;
791            Logging.debug("increasing zoom level to: {0}", currentZoomLevel);
792            zoomChanged();
793        } else {
794            Logging.warn("Current zoom level ("+currentZoomLevel+") could not be increased. "+
795                    "Max.zZoom Level "+this.getMaxZoomLvl()+" reached.");
796            return false;
797        }
798        return true;
799    }
800
801    /**
802     * Get the current zoom level of the layer
803     * @return the current zoom level
804     * @since 12603
805     */
806    public int getZoomLevel() {
807        return currentZoomLevel;
808    }
809
810    /**
811     * Sets the zoom level of the layer
812     * @param zoom zoom level
813     * @return true, when zoom has changed to desired value, false if it was outside supported zoom levels
814     */
815    public boolean setZoomLevel(int zoom) {
816        return setZoomLevel(zoom, true);
817    }
818
819    private boolean setZoomLevel(int zoom, boolean invalidate) {
820        if (zoom == currentZoomLevel) return true;
821        if (zoom > this.getMaxZoomLvl()) return false;
822        if (zoom < this.getMinZoomLvl()) return false;
823        currentZoomLevel = zoom;
824        zoomChanged(invalidate);
825        return true;
826    }
827
828    /**
829     * Check if zooming out is allowed
830     *
831     * @return    true, if zooming out is allowed (currentZoomLevel &gt; minZoomLevel)
832     */
833    public boolean zoomDecreaseAllowed() {
834        boolean zda = currentZoomLevel > this.getMinZoomLvl();
835        Logging.debug("zoomDecreaseAllowed(): {0} {1} vs. {2}", zda, currentZoomLevel, this.getMinZoomLvl());
836        return zda;
837    }
838
839    /**
840     * Zoom out from map.
841     *
842     * @return    true, if zoom increasing was successful, false othervise
843     */
844    public boolean decreaseZoomLevel() {
845        if (zoomDecreaseAllowed()) {
846            Logging.debug("decreasing zoom level to: {0}", currentZoomLevel);
847            currentZoomLevel--;
848            zoomChanged();
849        } else {
850            return false;
851        }
852        return true;
853    }
854
855    private Tile getOrCreateTile(TilePosition tilePosition) {
856        return getOrCreateTile(tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
857    }
858
859    private Tile getOrCreateTile(int x, int y, int zoom) {
860        Tile tile = getTile(x, y, zoom);
861        if (tile == null) {
862            if (coordinateConverter.requiresReprojection()) {
863                tile = new ReprojectionTile(tileSource, x, y, zoom);
864            } else {
865                tile = new Tile(tileSource, x, y, zoom);
866            }
867            tileCache.addTile(tile);
868        }
869        return tile;
870    }
871
872    private Tile getTile(TilePosition tilePosition) {
873        return getTile(tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
874    }
875
876    /**
877     * Returns tile at given position.
878     * This can and will return null for tiles that are not already in the cache.
879     * @param x tile number on the x axis of the tile to be retrieved
880     * @param y tile number on the y axis of the tile to be retrieved
881     * @param zoom zoom level of the tile to be retrieved
882     * @return tile at given position
883     */
884    private Tile getTile(int x, int y, int zoom) {
885        if (x < tileSource.getTileXMin(zoom) || x > tileSource.getTileXMax(zoom)
886         || y < tileSource.getTileYMin(zoom) || y > tileSource.getTileYMax(zoom))
887            return null;
888        return tileCache.getTile(tileSource, x, y, zoom);
889    }
890
891    private boolean loadTile(Tile tile, boolean force) {
892        if (tile == null)
893            return false;
894        if (!force && tile.isLoaded())
895            return false;
896        if (tile.isLoading())
897            return false;
898        tileLoader.createTileLoaderJob(tile).submit(force);
899        return true;
900    }
901
902    private TileSet getVisibleTileSet() {
903        if (!MainApplication.isDisplayingMapView())
904            return new TileSet();
905        ProjectionBounds bounds = MainApplication.getMap().mapView.getProjectionBounds();
906        return getTileSet(bounds, currentZoomLevel);
907    }
908
909    /**
910     * Load all visible tiles.
911     * @param force {@code true} to force loading if auto-load is disabled
912     * @since 11950
913     */
914    public void loadAllTiles(boolean force) {
915        TileSet ts = getVisibleTileSet();
916
917        // if there is more than 18 tiles on screen in any direction, do not load all tiles!
918        if (ts.tooLarge()) {
919            Logging.warn("Not downloading all tiles because there is more than 18 tiles on an axis!");
920            return;
921        }
922        ts.loadAllTiles(force);
923        invalidate();
924    }
925
926    /**
927     * Load all visible tiles in error.
928     * @param force {@code true} to force loading if auto-load is disabled
929     * @since 11950
930     */
931    public void loadAllErrorTiles(boolean force) {
932        TileSet ts = getVisibleTileSet();
933        ts.loadAllErrorTiles(force);
934        invalidate();
935    }
936
937    @Override
938    public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) {
939        boolean done = (infoflags & (ERROR | FRAMEBITS | ALLBITS)) != 0;
940        Logging.debug("imageUpdate() done: {0} calling repaint", done);
941
942        if (done) {
943            invalidate();
944        } else {
945            invalidateLater();
946        }
947        return !done;
948    }
949
950    /**
951     * Invalidate the layer at a time in the future so that the user still sees the interface responsive.
952     */
953    private void invalidateLater() {
954        GuiHelper.runInEDT(() -> {
955            if (!invalidateLaterTimer.isRunning()) {
956                invalidateLaterTimer.setRepeats(false);
957                invalidateLaterTimer.start();
958            }
959        });
960    }
961
962    private boolean imageLoaded(Image i) {
963        if (i == null)
964            return false;
965        int status = Toolkit.getDefaultToolkit().checkImage(i, -1, -1, this);
966        return (status & ALLBITS) != 0;
967    }
968
969    /**
970     * Returns the image for the given tile image is loaded.
971     * Otherwise returns  null.
972     *
973     * @param tile the Tile for which the image should be returned
974     * @return  the image of the tile or null.
975     */
976    private BufferedImage getLoadedTileImage(Tile tile) {
977        BufferedImage img = tile.getImage();
978        if (!imageLoaded(img))
979            return null;
980        return img;
981    }
982
983    /**
984     * Draw a tile image on screen.
985     * @param g the Graphics2D
986     * @param toDrawImg tile image
987     * @param anchorImage tile anchor in image coordinates
988     * @param anchorScreen tile anchor in screen coordinates
989     * @param clip clipping region in screen coordinates (can be null)
990     */
991    private void drawImageInside(Graphics2D g, BufferedImage toDrawImg, TileAnchor anchorImage, TileAnchor anchorScreen, Shape clip) {
992        AffineTransform imageToScreen = anchorImage.convert(anchorScreen);
993        Point2D screen0 = imageToScreen.transform(new Point.Double(0, 0), null);
994        Point2D screen1 = imageToScreen.transform(new Point.Double(
995                toDrawImg.getWidth(), toDrawImg.getHeight()), null);
996
997        Shape oldClip = null;
998        if (clip != null) {
999            oldClip = g.getClip();
1000            g.clip(clip);
1001        }
1002        g.drawImage(toDrawImg, (int) Math.round(screen0.getX()), (int) Math.round(screen0.getY()),
1003                (int) Math.round(screen1.getX()) - (int) Math.round(screen0.getX()),
1004                (int) Math.round(screen1.getY()) - (int) Math.round(screen0.getY()), this);
1005        if (clip != null) {
1006            g.setClip(oldClip);
1007        }
1008    }
1009
1010    private List<Tile> paintTileImages(Graphics2D g, TileSet ts) {
1011        Object paintMutex = new Object();
1012        List<TilePosition> missed = Collections.synchronizedList(new ArrayList<>());
1013        ts.visitTiles(tile -> {
1014            boolean miss = false;
1015            BufferedImage img = null;
1016            TileAnchor anchorImage = null;
1017            if (!tile.isLoaded() || tile.hasError()) {
1018                miss = true;
1019            } else {
1020                synchronized (tile) {
1021                    img = getLoadedTileImage(tile);
1022                    anchorImage = getAnchor(tile, img);
1023                }
1024                if (img == null || anchorImage == null) {
1025                    miss = true;
1026                }
1027            }
1028            if (miss) {
1029                missed.add(new TilePosition(tile));
1030                return;
1031            }
1032
1033            img = applyImageProcessors(img);
1034
1035            TileAnchor anchorScreen = coordinateConverter.getScreenAnchorForTile(tile);
1036            synchronized (paintMutex) {
1037                //cannot paint in parallel
1038                drawImageInside(g, img, anchorImage, anchorScreen, null);
1039            }
1040            MapView mapView = MainApplication.getMap().mapView;
1041            if (tile instanceof ReprojectionTile && ((ReprojectionTile) tile).needsUpdate(mapView.getScale())) {
1042                // This means we have a reprojected tile in memory cache, but not at
1043                // current scale. Generally, the positioning of the tile will still
1044                // be correct, but for best image quality, the tile should be
1045                // reprojected to the target scale. The original tile image should
1046                // still be in disk cache, so this is fairly cheap.
1047                ((ReprojectionTile) tile).invalidate();
1048                loadTile(tile, false);
1049            }
1050
1051        }, missed::add);
1052
1053        return missed.stream().map(this::getOrCreateTile).collect(Collectors.toList());
1054    }
1055
1056    // This function is called for several zoom levels, not just the current one.
1057    // It should not trigger any tiles to be downloaded.
1058    // It should also avoid polluting the tile cache with any tiles since these tiles are not mandatory.
1059    //
1060    // The "border" tile tells us the boundaries of where we may drawn.
1061    // It will not be from the zoom level that is being drawn currently.
1062    // If drawing the displayZoomLevel, border is null and we draw the entire tile set.
1063    private List<Tile> paintTileImages(Graphics2D g, TileSet ts, int zoom, Tile border) {
1064        if (zoom <= 0) return Collections.emptyList();
1065        Shape borderClip = coordinateConverter.getTileShapeScreen(border);
1066        List<Tile> missedTiles = new LinkedList<>();
1067        // The callers of this code *require* that we return any tiles that we do not draw in missedTiles.
1068        // ts.allExistingTiles() by default will only return already-existing tiles.
1069        // However, we need to return *all* tiles to the callers, so force creation here.
1070        for (Tile tile : ts.allTilesCreate()) {
1071            boolean miss = false;
1072            BufferedImage img = null;
1073            TileAnchor anchorImage = null;
1074            if (!tile.isLoaded() || tile.hasError()) {
1075                miss = true;
1076            } else {
1077                synchronized (tile) {
1078                    img = getLoadedTileImage(tile);
1079                    anchorImage = getAnchor(tile, img);
1080                }
1081
1082                if (img == null || anchorImage == null) {
1083                    miss = true;
1084                }
1085            }
1086            if (miss) {
1087                missedTiles.add(tile);
1088                continue;
1089            }
1090
1091            // applying all filters to this layer
1092            img = applyImageProcessors(img);
1093
1094            Shape clip;
1095            if (tileSource.isInside(tile, border)) {
1096                clip = null;
1097            } else if (tileSource.isInside(border, tile)) {
1098                clip = borderClip;
1099            } else {
1100                continue;
1101            }
1102            TileAnchor anchorScreen = coordinateConverter.getScreenAnchorForTile(tile);
1103            drawImageInside(g, img, anchorImage, anchorScreen, clip);
1104        }
1105        return missedTiles;
1106    }
1107
1108    private static TileAnchor getAnchor(Tile tile, BufferedImage image) {
1109        if (tile instanceof ReprojectionTile) {
1110            return ((ReprojectionTile) tile).getAnchor();
1111        } else if (image != null) {
1112            return new TileAnchor(new Point.Double(0, 0), new Point.Double(image.getWidth(), image.getHeight()));
1113        } else {
1114            return null;
1115        }
1116    }
1117
1118    private void myDrawString(Graphics g, String text, int x, int y) {
1119        Color oldColor = g.getColor();
1120        String textToDraw = text;
1121        if (g.getFontMetrics().stringWidth(text) > tileSource.getTileSize()) {
1122            // text longer than tile size, split it
1123            StringBuilder line = new StringBuilder();
1124            StringBuilder ret = new StringBuilder();
1125            for (String s: text.split(" ")) {
1126                if (g.getFontMetrics().stringWidth(line.toString() + s) > tileSource.getTileSize()) {
1127                    ret.append(line).append('\n');
1128                    line.setLength(0);
1129                }
1130                line.append(s).append(' ');
1131            }
1132            ret.append(line);
1133            textToDraw = ret.toString();
1134        }
1135        int offset = 0;
1136        for (String s: textToDraw.split("\n")) {
1137            g.setColor(Color.black);
1138            g.drawString(s, x + 1, y + offset + 1);
1139            g.setColor(oldColor);
1140            g.drawString(s, x, y + offset);
1141            offset += g.getFontMetrics().getHeight() + 3;
1142        }
1143    }
1144
1145    private void paintTileText(Tile tile, Graphics2D g) {
1146        if (tile == null) {
1147            return;
1148        }
1149        Point2D p = coordinateConverter.getPixelForTile(tile);
1150        int fontHeight = g.getFontMetrics().getHeight();
1151        int x = (int) p.getX();
1152        int y = (int) p.getY();
1153        int texty = y + 2 + fontHeight;
1154
1155        /*if (PROP_DRAW_DEBUG.get()) {
1156            myDrawString(g, "x=" + t.getXtile() + " y=" + t.getYtile() + " z=" + zoom + "", p.x + 2, texty);
1157            texty += 1 + fontHeight;
1158            if ((t.getXtile() % 32 == 0) && (t.getYtile() % 32 == 0)) {
1159                myDrawString(g, "x=" + t.getXtile() / 32 + " y=" + t.getYtile() / 32 + " z=7", p.x + 2, texty);
1160                texty += 1 + fontHeight;
1161            }
1162        }
1163
1164        String tileStatus = tile.getStatus();
1165        if (!tile.isLoaded() && PROP_DRAW_DEBUG.get()) {
1166            myDrawString(g, tr("image " + tileStatus), p.x + 2, texty);
1167            texty += 1 + fontHeight;
1168        }*/
1169
1170        if (tile.hasError() && getDisplaySettings().isShowErrors()) {
1171            String errorMessage = tr(tile.getErrorMessage());
1172            if (errorMessage != null) {
1173                if (!errorMessage.startsWith("Error") && !errorMessage.startsWith(tr("Error"))) {
1174                    errorMessage = tr("Error") + ": " + errorMessage;
1175                }
1176                myDrawString(g, errorMessage, x + 2, texty);
1177            }
1178            //texty += 1 + fontHeight;
1179        }
1180
1181        if (Logging.isDebugEnabled()) {
1182            // draw tile outline in semi-transparent red
1183            g.setColor(new Color(255, 0, 0, 50));
1184            g.draw(coordinateConverter.getTileShapeScreen(tile));
1185        }
1186    }
1187
1188    private LatLon getShiftedLatLon(EastNorth en) {
1189        return coordinateConverter.getProjecting().eastNorth2latlonClamped(en);
1190    }
1191
1192    private ICoordinate getShiftedCoord(EastNorth en) {
1193        return CoordinateConversion.llToCoor(getShiftedLatLon(en));
1194    }
1195
1196    private final TileSet nullTileSet = new TileSet();
1197
1198    protected class TileSet extends TileRange {
1199
1200        private volatile TileSetInfo info;
1201
1202        protected TileSet(TileXY t1, TileXY t2, int zoom) {
1203            super(t1, t2, zoom);
1204            sanitize();
1205        }
1206
1207        protected TileSet(TileRange range) {
1208            super(range);
1209            sanitize();
1210        }
1211
1212        /**
1213         * null tile set
1214         */
1215        private TileSet() {
1216            // default
1217        }
1218
1219        protected void sanitize() {
1220            minX = Utils.clamp(minX, tileSource.getTileXMin(zoom), tileSource.getTileXMax(zoom));
1221            maxX = Utils.clamp(maxX, tileSource.getTileXMin(zoom), tileSource.getTileXMax(zoom));
1222            minY = Utils.clamp(minY, tileSource.getTileYMin(zoom), tileSource.getTileYMax(zoom));
1223            maxY = Utils.clamp(maxY, tileSource.getTileYMin(zoom), tileSource.getTileYMax(zoom));
1224        }
1225
1226        private boolean tooSmall() {
1227            return this.tilesSpanned() < 2.1;
1228        }
1229
1230        private boolean tooLarge() {
1231            return insane() || this.tilesSpanned() > 20;
1232        }
1233
1234        private boolean insane() {
1235            return tileCache == null || size() > tileCache.getCacheSize();
1236        }
1237
1238        /**
1239         * Get all tiles represented by this TileSet that are already in the tileCache.
1240         * @return all tiles represented by this TileSet that are already in the tileCache
1241         */
1242        private List<Tile> allExistingTiles() {
1243            return allTiles(AbstractTileSourceLayer.this::getTile);
1244        }
1245
1246        private List<Tile> allTilesCreate() {
1247            return allTiles(AbstractTileSourceLayer.this::getOrCreateTile);
1248        }
1249
1250        private List<Tile> allTiles(Function<TilePosition, Tile> mapper) {
1251            return tilePositions().map(mapper).filter(Objects::nonNull).collect(Collectors.toList());
1252        }
1253
1254        /**
1255         * Gets a stream of all tile positions in this set
1256         * @return A stream of all positions
1257         */
1258        public Stream<TilePosition> tilePositions() {
1259            if (zoom == 0 || this.insane()) {
1260                return Stream.empty(); // Tileset is either empty or too large
1261            } else {
1262                return IntStream.rangeClosed(minX, maxX).mapToObj(
1263                        x -> IntStream.rangeClosed(minY, maxY).mapToObj(y -> new TilePosition(x, y, zoom))
1264                        ).flatMap(Function.identity());
1265            }
1266        }
1267
1268        private List<Tile> allLoadedTiles() {
1269            return allExistingTiles().stream().filter(Tile::isLoaded).collect(Collectors.toList());
1270        }
1271
1272        /**
1273         * @return comparator, that sorts the tiles from the center to the edge of the current screen
1274         */
1275        private Comparator<Tile> getTileDistanceComparator() {
1276            final int centerX = (int) Math.ceil((minX + maxX) / 2d);
1277            final int centerY = (int) Math.ceil((minY + maxY) / 2d);
1278            return Comparator.comparingInt(t -> Math.abs(t.getXtile() - centerX) + Math.abs(t.getYtile() - centerY));
1279        }
1280
1281        private void loadAllTiles(boolean force) {
1282            if (!getDisplaySettings().isAutoLoad() && !force)
1283                return;
1284            List<Tile> allTiles = allTilesCreate();
1285            allTiles.sort(getTileDistanceComparator());
1286            for (Tile t : allTiles) {
1287                loadTile(t, force);
1288            }
1289        }
1290
1291        private void loadAllErrorTiles(boolean force) {
1292            if (!getDisplaySettings().isAutoLoad() && !force)
1293                return;
1294            for (Tile t : this.allTilesCreate()) {
1295                if (t.hasError()) {
1296                    tileLoader.createTileLoaderJob(t).submit(force);
1297                }
1298            }
1299        }
1300
1301        /**
1302         * Call the given paint method for all tiles in this tile set.<p>
1303         * Uses a parallel stream.
1304         * @param visitor A visitor to call for each tile.
1305         * @param missed a consumer to call for each missed tile.
1306         */
1307        public void visitTiles(Consumer<Tile> visitor, Consumer<TilePosition> missed) {
1308            tilePositions().parallel().forEach(tp -> visitTilePosition(visitor, tp, missed));
1309        }
1310
1311        private void visitTilePosition(Consumer<Tile> visitor, TilePosition tp, Consumer<TilePosition> missed) {
1312            Tile tile = getTile(tp);
1313            if (tile == null) {
1314                missed.accept(tp);
1315            } else {
1316                visitor.accept(tile);
1317            }
1318        }
1319
1320        /**
1321         * Check if there is any tile fully loaded without error.
1322         * @return true if there is any tile fully loaded without error
1323         */
1324        public boolean hasVisibleTiles() {
1325            return getTileSetInfo().hasVisibleTiles;
1326        }
1327
1328        /**
1329         * Check if there there is a tile that is overzoomed.
1330         * <p>
1331         * I.e. the server response for one tile was "there is no tile here".
1332         * This usually happens when zoomed in too much. The limit depends on
1333         * the region, so at the edge of such a region, some tiles may be
1334         * available and some not.
1335         * @return true if there there is a tile that is overzoomed
1336         */
1337        public boolean hasOverzoomedTiles() {
1338            return getTileSetInfo().hasOverzoomedTiles;
1339        }
1340
1341        /**
1342         * Check if there are tiles still loading.
1343         * <p>
1344         * This is the case if there is a tile not yet in the cache, or in the
1345         * cache but marked as loading ({@link Tile#isLoading()}.
1346         * @return true if there are tiles still loading
1347         */
1348        public boolean hasLoadingTiles() {
1349            return getTileSetInfo().hasLoadingTiles;
1350        }
1351
1352        /**
1353         * Check if all tiles in the range are fully loaded.
1354         * <p>
1355         * A tile is considered to be fully loaded even if the result of loading
1356         * the tile was an error.
1357         * @return true if all tiles in the range are fully loaded
1358         */
1359        public boolean hasAllLoadedTiles() {
1360            return getTileSetInfo().hasAllLoadedTiles;
1361        }
1362
1363        private TileSetInfo getTileSetInfo() {
1364            if (info == null) {
1365                synchronized (this) {
1366                    if (info == null) {
1367                        List<Tile> allTiles = this.allExistingTiles();
1368                        TileSetInfo newInfo = new TileSetInfo();
1369                        newInfo.hasLoadingTiles = allTiles.size() < this.size();
1370                        newInfo.hasAllLoadedTiles = true;
1371                        for (Tile t : allTiles) {
1372                            if ("no-tile".equals(t.getValue("tile-info"))) {
1373                                newInfo.hasOverzoomedTiles = true;
1374                            }
1375                            if (t.isLoaded()) {
1376                                if (!t.hasError()) {
1377                                    newInfo.hasVisibleTiles = true;
1378                                }
1379                            } else {
1380                                newInfo.hasAllLoadedTiles = false;
1381                                if (t.isLoading()) {
1382                                    newInfo.hasLoadingTiles = true;
1383                                }
1384                            }
1385                        }
1386                        info = newInfo;
1387                    }
1388                }
1389            }
1390            return info;
1391        }
1392
1393        @Override
1394        public String toString() {
1395            return getClass().getName() + ": zoom: " + zoom + " X(" + minX + ", " + maxX + ") Y(" + minY + ", " + maxY + ") size: " + size();
1396        }
1397    }
1398
1399    /**
1400     * Data container to hold information about a {@code TileSet} class.
1401     */
1402    private static class TileSetInfo {
1403        boolean hasVisibleTiles;
1404        boolean hasOverzoomedTiles;
1405        boolean hasLoadingTiles;
1406        boolean hasAllLoadedTiles;
1407    }
1408
1409    /**
1410     * Create a TileSet by EastNorth bbox taking a layer shift in account
1411     * @param bounds the EastNorth bounds
1412     * @param zoom zoom level
1413     * @return the tile set
1414     */
1415    protected TileSet getTileSet(ProjectionBounds bounds, int zoom) {
1416        if (zoom == 0)
1417            return new TileSet();
1418        TileXY t1, t2;
1419        IProjected topLeftUnshifted = coordinateConverter.shiftDisplayToServer(bounds.getMin());
1420        IProjected botRightUnshifted = coordinateConverter.shiftDisplayToServer(bounds.getMax());
1421        if (coordinateConverter.requiresReprojection()) {
1422            Projection projServer = Projections.getProjectionByCode(tileSource.getServerCRS());
1423            if (projServer == null) {
1424                throw new IllegalStateException(tileSource.toString());
1425            }
1426            ProjectionBounds projBounds = new ProjectionBounds(
1427                    CoordinateConversion.projToEn(topLeftUnshifted),
1428                    CoordinateConversion.projToEn(botRightUnshifted));
1429            ProjectionBounds bbox = projServer.getEastNorthBoundsBox(projBounds, ProjectionRegistry.getProjection());
1430            t1 = tileSource.projectedToTileXY(CoordinateConversion.enToProj(bbox.getMin()), zoom);
1431            t2 = tileSource.projectedToTileXY(CoordinateConversion.enToProj(bbox.getMax()), zoom);
1432        } else {
1433            t1 = tileSource.projectedToTileXY(topLeftUnshifted, zoom);
1434            t2 = tileSource.projectedToTileXY(botRightUnshifted, zoom);
1435        }
1436        return new TileSet(t1, t2, zoom);
1437    }
1438
1439    private class DeepTileSet {
1440        private final ProjectionBounds bounds;
1441        private final int minZoom, maxZoom;
1442        private final TileSet[] tileSets;
1443
1444        @SuppressWarnings("unchecked")
1445        DeepTileSet(ProjectionBounds bounds, int minZoom, int maxZoom) {
1446            this.bounds = bounds;
1447            this.minZoom = minZoom;
1448            this.maxZoom = maxZoom;
1449            if (minZoom > maxZoom) {
1450                throw new IllegalArgumentException(minZoom + " > " + maxZoom);
1451            }
1452            this.tileSets = new AbstractTileSourceLayer.TileSet[maxZoom - minZoom + 1];
1453        }
1454
1455        public TileSet getTileSet(int zoom) {
1456            if (zoom < minZoom)
1457                return nullTileSet;
1458            synchronized (tileSets) {
1459                TileSet ts = tileSets[zoom-minZoom];
1460                if (ts == null) {
1461                    ts = AbstractTileSourceLayer.this.getTileSet(bounds, zoom);
1462                    tileSets[zoom-minZoom] = ts;
1463                }
1464                return ts;
1465            }
1466        }
1467    }
1468
1469    @Override
1470    public void paint(Graphics2D g, MapView mv, Bounds bounds) {
1471        // old and unused.
1472    }
1473
1474    private void drawInViewArea(Graphics2D g, MapView mv, ProjectionBounds pb) {
1475        int zoom = currentZoomLevel;
1476        if (getDisplaySettings().isAutoZoom()) {
1477            zoom = getBestZoom();
1478        }
1479
1480        DeepTileSet dts = new DeepTileSet(pb, getMinZoomLvl(), zoom);
1481
1482        int displayZoomLevel = zoom;
1483
1484        boolean noTilesAtZoom = false;
1485        if (getDisplaySettings().isAutoZoom() && getDisplaySettings().isAutoLoad()) {
1486            // Auto-detection of tilesource maxzoom (currently fully works only for Bing)
1487            TileSet ts0 = dts.getTileSet(zoom);
1488            if (!ts0.hasVisibleTiles() && (!ts0.hasLoadingTiles() || ts0.hasOverzoomedTiles())) {
1489                noTilesAtZoom = true;
1490            }
1491            // Find highest zoom level with at least one visible tile
1492            for (int tmpZoom = zoom; tmpZoom > dts.minZoom; tmpZoom--) {
1493                if (dts.getTileSet(tmpZoom).hasVisibleTiles()) {
1494                    displayZoomLevel = tmpZoom;
1495                    break;
1496                }
1497            }
1498            // Do binary search between currentZoomLevel and displayZoomLevel
1499            while (zoom > displayZoomLevel && !ts0.hasVisibleTiles() && ts0.hasOverzoomedTiles()) {
1500                zoom = (zoom + displayZoomLevel)/2;
1501                ts0 = dts.getTileSet(zoom);
1502            }
1503
1504            setZoomLevel(zoom, false);
1505
1506            // If all tiles at displayZoomLevel is loaded, load all tiles at next zoom level
1507            // to make sure there're really no more zoom levels
1508            // loading is done in the next if section
1509            if (zoom == displayZoomLevel && !ts0.hasLoadingTiles() && zoom < dts.maxZoom) {
1510                zoom++;
1511                ts0 = dts.getTileSet(zoom);
1512            }
1513            // When we have overzoomed tiles and all tiles at current zoomlevel is loaded,
1514            // load tiles at previovus zoomlevels until we have all tiles on screen is loaded.
1515            // loading is done in the next if section
1516            while (zoom > dts.minZoom && ts0.hasOverzoomedTiles() && !ts0.hasLoadingTiles()) {
1517                zoom--;
1518                ts0 = dts.getTileSet(zoom);
1519            }
1520        } else if (getDisplaySettings().isAutoZoom()) {
1521            setZoomLevel(zoom, false);
1522        }
1523        TileSet ts = dts.getTileSet(zoom);
1524
1525        // Too many tiles... refuse to download
1526        if (!ts.tooLarge()) {
1527            // try to load tiles from desired zoom level, no matter what we will show (for example, tiles from previous zoom level
1528            // on zoom in)
1529            ts.loadAllTiles(false);
1530        }
1531
1532        if (displayZoomLevel != zoom) {
1533            ts = dts.getTileSet(displayZoomLevel);
1534            if (!dts.getTileSet(displayZoomLevel).hasAllLoadedTiles() && displayZoomLevel < zoom) {
1535                // if we are showing tiles from lower zoom level, ensure that all tiles are loaded as they are few,
1536                // and should not trash the tile cache
1537                // This is especially needed when dts.getTileSet(zoom).tooLarge() is true and we are not loading tiles
1538                ts.loadAllTiles(false);
1539            }
1540        }
1541
1542        g.setColor(Color.DARK_GRAY);
1543
1544        List<Tile> missedTiles = this.paintTileImages(g, ts);
1545        int[] otherZooms = {1, 2, -1, -2, -3, -4, -5};
1546        for (int zoomOffset : otherZooms) {
1547            if (!getDisplaySettings().isAutoZoom()) {
1548                break;
1549            }
1550            int newzoom = displayZoomLevel + zoomOffset;
1551            if (newzoom < getMinZoomLvl() || newzoom > getMaxZoomLvl()) {
1552                continue;
1553            }
1554            if (missedTiles.isEmpty()) {
1555                break;
1556            }
1557            List<Tile> newlyMissedTiles = new LinkedList<>();
1558            for (Tile missed : missedTiles) {
1559                if (zoomOffset > 0 && "no-tile".equals(missed.getValue("tile-info"))) {
1560                    // Don't try to paint from higher zoom levels when tile is overzoomed
1561                    newlyMissedTiles.add(missed);
1562                    continue;
1563                }
1564                TileSet ts2 = new TileSet(tileSource.getCoveringTileRange(missed, newzoom));
1565                // Instantiating large TileSets is expensive. If there are no loaded tiles, don't bother even trying.
1566                if (ts2.allLoadedTiles().isEmpty()) {
1567                    if (zoomOffset > 0) {
1568                        newlyMissedTiles.add(missed);
1569                        continue;
1570                    } else {
1571                        /*
1572                         *  We have negative zoom offset. Try to load tiles from lower zoom levels, as they may be not present
1573                         *  in tile cache (e.g. when user panned the map or opened layer above zoom level, for which tiles are present.
1574                         *  This will ensure, that tileCache is populated with tiles from lower zoom levels so it will be possible to
1575                         *  use them to paint overzoomed tiles.
1576                         *  See: #14562
1577                         */
1578                        ts2.loadAllTiles(false);
1579                    }
1580                }
1581                if (ts2.tooLarge()) {
1582                    continue;
1583                }
1584                newlyMissedTiles.addAll(this.paintTileImages(g, ts2, newzoom, missed));
1585            }
1586            missedTiles = newlyMissedTiles;
1587        }
1588        if (Logging.isDebugEnabled() && !missedTiles.isEmpty()) {
1589            Logging.debug("still missed {0} in the end", missedTiles.size());
1590        }
1591        g.setColor(Color.red);
1592        g.setFont(InfoFont);
1593
1594        // The current zoom tileset should have all of its tiles due to the loadAllTiles(), unless it to tooLarge()
1595        for (Tile t : ts.allExistingTiles()) {
1596            this.paintTileText(t, g);
1597        }
1598
1599        EastNorth min = pb.getMin();
1600        EastNorth max = pb.getMax();
1601        attribution.paintAttribution(g, mv.getWidth(), mv.getHeight(), getShiftedCoord(min), getShiftedCoord(max),
1602                displayZoomLevel, this);
1603
1604        g.setColor(Color.lightGray);
1605
1606        if (ts.insane()) {
1607            myDrawString(g, tr("zoom in to load any tiles"), 120, 120);
1608        } else if (ts.tooLarge()) {
1609            myDrawString(g, tr("zoom in to load more tiles"), 120, 120);
1610        } else if (!getDisplaySettings().isAutoZoom() && ts.tooSmall()) {
1611            myDrawString(g, tr("increase tiles zoom level (change resolution) to see more detail"), 120, 120);
1612        }
1613        if (noTilesAtZoom) {
1614            myDrawString(g, tr("No tiles at this zoom level"), 120, 120);
1615        }
1616        if (Logging.isDebugEnabled()) {
1617            myDrawString(g, tr("Current zoom: {0}", currentZoomLevel), 50, 140);
1618            myDrawString(g, tr("Display zoom: {0}", displayZoomLevel), 50, 155);
1619            myDrawString(g, tr("Pixel scale: {0}", getScaleFactor(currentZoomLevel)), 50, 170);
1620            myDrawString(g, tr("Best zoom: {0}", getBestZoom()), 50, 185);
1621            myDrawString(g, tr("Estimated cache size: {0}", estimateTileCacheSize()), 50, 200);
1622            if (tileLoader instanceof TMSCachedTileLoader) {
1623                int offset = 200;
1624                for (String part: ((TMSCachedTileLoader) tileLoader).getStats().split("\n")) {
1625                    offset += 15;
1626                    myDrawString(g, tr("Cache stats: {0}", part), 50, offset);
1627                }
1628            }
1629        }
1630    }
1631
1632    /**
1633     * Returns tile for a pixel position.<p>
1634     * This isn't very efficient, but it is only used when the user right-clicks on the map.
1635     * @param px pixel X coordinate
1636     * @param py pixel Y coordinate
1637     * @return Tile at pixel position
1638     */
1639    private Tile getTileForPixelpos(int px, int py) {
1640        Logging.debug("getTileForPixelpos({0}, {1})", px, py);
1641        TileXY xy = coordinateConverter.getTileforPixel(px, py, currentZoomLevel);
1642        return getTile(xy.getXIndex(), xy.getYIndex(), currentZoomLevel);
1643    }
1644
1645    /**
1646     * Class to store a menu action and the class it belongs to.
1647     */
1648    private static class MenuAddition {
1649        final Action addition;
1650        @SuppressWarnings("rawtypes")
1651        final Class<? extends AbstractTileSourceLayer> clazz;
1652
1653        @SuppressWarnings("rawtypes")
1654        MenuAddition(Action addition, Class<? extends AbstractTileSourceLayer> clazz) {
1655            this.addition = addition;
1656            this.clazz = clazz;
1657        }
1658    }
1659
1660    /**
1661     * Register an additional layer context menu entry.
1662     *
1663     * @param addition additional menu action
1664     * @since 11197
1665     */
1666    public static void registerMenuAddition(Action addition) {
1667        menuAdditions.add(new MenuAddition(addition, AbstractTileSourceLayer.class));
1668    }
1669
1670    /**
1671     * Register an additional layer context menu entry for a imagery layer
1672     * class.  The menu entry is valid for the specified class and subclasses
1673     * thereof only.
1674     * <p>
1675     * Example:
1676     * <pre>
1677     * TMSLayer.registerMenuAddition(new TMSSpecificAction(), TMSLayer.class);
1678     * </pre>
1679     *
1680     * @param addition additional menu action
1681     * @param clazz class the menu action is registered for
1682     * @since 11197
1683     */
1684    public static void registerMenuAddition(Action addition,
1685                                            Class<? extends AbstractTileSourceLayer<?>> clazz) {
1686        menuAdditions.add(new MenuAddition(addition, clazz));
1687    }
1688
1689    /**
1690     * Prepare list of additional layer context menu entries.  The list is
1691     * empty if there are no additional menu entries.
1692     *
1693     * @return list of additional layer context menu entries
1694     */
1695    private List<Action> getMenuAdditions() {
1696        final LinkedList<Action> menuAdds = new LinkedList<>();
1697        for (MenuAddition menuAdd: menuAdditions) {
1698            if (menuAdd.clazz.isInstance(this)) {
1699                menuAdds.add(menuAdd.addition);
1700            }
1701        }
1702        if (!menuAdds.isEmpty()) {
1703            menuAdds.addFirst(SeparatorLayerAction.INSTANCE);
1704        }
1705        return menuAdds;
1706    }
1707
1708    @Override
1709    public Action[] getMenuEntries() {
1710        ArrayList<Action> actions = new ArrayList<>();
1711        actions.addAll(Arrays.asList(getLayerListEntries()));
1712        actions.addAll(Arrays.asList(getCommonEntries()));
1713        actions.addAll(getMenuAdditions());
1714        actions.add(SeparatorLayerAction.INSTANCE);
1715        actions.add(new LayerListPopup.InfoAction(this));
1716        return actions.toArray(new Action[0]);
1717    }
1718
1719    /**
1720     * Returns the contextual menu entries in layer list dialog.
1721     * @return the contextual menu entries in layer list dialog
1722     */
1723    public Action[] getLayerListEntries() {
1724        return new Action[] {
1725            LayerListDialog.getInstance().createActivateLayerAction(this),
1726            LayerListDialog.getInstance().createShowHideLayerAction(),
1727            LayerListDialog.getInstance().createDeleteLayerAction(),
1728            SeparatorLayerAction.INSTANCE,
1729            // color,
1730            new OffsetAction(),
1731            new RenameLayerAction(this.getAssociatedFile(), this),
1732            SeparatorLayerAction.INSTANCE
1733        };
1734    }
1735
1736    /**
1737     * Returns the common menu entries.
1738     * @return the common menu entries
1739     */
1740    public Action[] getCommonEntries() {
1741        return new Action[] {
1742            new AutoLoadTilesAction(this),
1743            new AutoZoomAction(this),
1744            new ShowErrorsAction(this),
1745            new IncreaseZoomAction(this),
1746            new DecreaseZoomAction(this),
1747            new ZoomToBestAction(this),
1748            new ZoomToNativeLevelAction(this),
1749            new FlushTileCacheAction(this),
1750            new LoadErroneousTilesAction(this),
1751            new LoadAllTilesAction(this)
1752        };
1753    }
1754
1755    @Override
1756    public String getToolTipText() {
1757        if (getDisplaySettings().isAutoLoad()) {
1758            return tr("{0} ({1}), automatically downloading in zoom {2}", this.getClass().getSimpleName(), getName(), currentZoomLevel);
1759        } else {
1760            return tr("{0} ({1}), downloading in zoom {2}", this.getClass().getSimpleName(), getName(), currentZoomLevel);
1761        }
1762    }
1763
1764    @Override
1765    public void visitBoundingBox(BoundingXYVisitor v) {
1766    }
1767
1768    /**
1769     * Task responsible for precaching imagery along the gpx track
1770     *
1771     */
1772    public class PrecacheTask implements TileLoaderListener {
1773        private final ProgressMonitor progressMonitor;
1774        private final int totalCount;
1775        private final AtomicInteger processedCount = new AtomicInteger(0);
1776        private final TileLoader tileLoader;
1777        private final Set<Tile> requestedTiles;
1778
1779        /**
1780         * Constructs a new {@code PrecacheTask}.
1781         * @param progressMonitor that will be notified about progess of the task
1782         * @param bufferY buffer Y in degrees around which to download tiles
1783         * @param bufferX buffer X in degrees around which to download tiles
1784         * @param points list of points along which to download
1785         */
1786        public PrecacheTask(ProgressMonitor progressMonitor, List<LatLon> points, double bufferX, double bufferY) {
1787            this.progressMonitor = progressMonitor;
1788            this.tileLoader = getTileLoaderFactory().makeTileLoader(this, getHeaders(tileSource), minimumTileExpire);
1789            if (this.tileLoader instanceof TMSCachedTileLoader) {
1790                ((TMSCachedTileLoader) this.tileLoader).setDownloadExecutor(
1791                        TMSCachedTileLoader.getNewThreadPoolExecutor("Precache downloader"));
1792            }
1793            requestedTiles = new ConcurrentSkipListSet<>(
1794                    (o1, o2) -> String.CASE_INSENSITIVE_ORDER.compare(o1.getKey(), o2.getKey()));
1795            for (LatLon point: points) {
1796                TileXY minTile = tileSource.latLonToTileXY(point.lat() - bufferY, point.lon() - bufferX, currentZoomLevel);
1797                TileXY curTile = tileSource.latLonToTileXY(CoordinateConversion.llToCoor(point), currentZoomLevel);
1798                TileXY maxTile = tileSource.latLonToTileXY(point.lat() + bufferY, point.lon() + bufferX, currentZoomLevel);
1799
1800                // take at least one tile of buffer
1801                int minY = Math.min(curTile.getYIndex() - 1, minTile.getYIndex());
1802                int maxY = Math.max(curTile.getYIndex() + 1, maxTile.getYIndex());
1803                int minX = Math.min(curTile.getXIndex() - 1, minTile.getXIndex());
1804                int maxX = Math.max(curTile.getXIndex() + 1, maxTile.getXIndex());
1805
1806                for (int x = minX; x <= maxX; x++) {
1807                    for (int y = minY; y <= maxY; y++) {
1808                        requestedTiles.add(new Tile(tileSource, x, y, currentZoomLevel));
1809                    }
1810                }
1811            }
1812
1813            this.totalCount = requestedTiles.size();
1814            this.progressMonitor.setTicksCount(requestedTiles.size());
1815
1816        }
1817
1818        /**
1819         * @return true, if all is done
1820         */
1821        public boolean isFinished() {
1822            return processedCount.get() >= totalCount;
1823        }
1824
1825        /**
1826         * @return total number of tiles to download
1827         */
1828        public int getTotalCount() {
1829            return totalCount;
1830        }
1831
1832        /**
1833         * cancel the task
1834         */
1835        public void cancel() {
1836            if (tileLoader instanceof TMSCachedTileLoader) {
1837                ((TMSCachedTileLoader) tileLoader).cancelOutstandingTasks();
1838            }
1839        }
1840
1841        @Override
1842        public void tileLoadingFinished(Tile tile, boolean success) {
1843            int processed = this.processedCount.incrementAndGet();
1844            if (success) {
1845                synchronized (progressMonitor) {
1846                    if (!this.progressMonitor.isCanceled()) {
1847                        this.progressMonitor.worked(1);
1848                        this.progressMonitor.setCustomText(tr("Downloaded {0}/{1} tiles", processed, totalCount));
1849                    }
1850                }
1851            } else {
1852                Logging.warn("Tile loading failure: " + tile + " - " + tile.getErrorMessage());
1853            }
1854        }
1855
1856        /**
1857         * @return tile loader that is used to load the tiles
1858         */
1859        public TileLoader getTileLoader() {
1860            return tileLoader;
1861        }
1862
1863        /**
1864         * Execute the download
1865         */
1866        public void run() {
1867            TileLoader loader = getTileLoader();
1868            for (Tile t: requestedTiles) {
1869                if (!progressMonitor.isCanceled()) {
1870                    loader.createTileLoaderJob(t).submit();
1871                }
1872            }
1873
1874        }
1875    }
1876
1877    /**
1878     * Calculates tiles, that needs to be downloaded to cache, gets a current tile loader and creates a task to download
1879     * all of the tiles. Buffer contains at least one tile.
1880     *
1881     * To prevent accidental clear of the queue, new download executor is created with separate queue
1882     *
1883     * @param progressMonitor progress monitor for download task
1884     * @param points lat/lon coordinates to download
1885     * @param bufferX how many units in current Coordinate Reference System to cover in X axis in both sides
1886     * @param bufferY how many units in current Coordinate Reference System to cover in Y axis in both sides
1887     * @return precache task representing download task
1888     */
1889    public AbstractTileSourceLayer<T>.PrecacheTask getDownloadAreaToCacheTask(final ProgressMonitor progressMonitor, List<LatLon> points,
1890            double bufferX, double bufferY) {
1891        return new PrecacheTask(progressMonitor, points, bufferX, bufferY);
1892    }
1893
1894    @Override
1895    public boolean isSavable() {
1896        return true; // With WMSLayerExporter
1897    }
1898
1899    @Override
1900    public File createAndOpenSaveFileChooser() {
1901        return SaveActionBase.createAndOpenSaveFileChooser(tr("Save WMS file"), WMSLayerImporter.FILE_FILTER);
1902    }
1903
1904    @Override
1905    public synchronized void destroy() {
1906        super.destroy();
1907        MapView.removeZoomChangeListener(this);
1908        adjustAction.destroy();
1909    }
1910
1911    private class TileSourcePainter extends CompatibilityModeLayerPainter {
1912        /** The memory handle that will hold our tile source. */
1913        private MemoryHandle<?> memory;
1914
1915        @Override
1916        public void paint(MapViewGraphics graphics) {
1917            allocateCacheMemory();
1918            if (memory != null) {
1919                doPaint(graphics);
1920            }
1921        }
1922
1923        private void doPaint(MapViewGraphics graphics) {
1924            try {
1925                drawInViewArea(graphics.getDefaultGraphics(), graphics.getMapView(), graphics.getClipBounds().getProjectionBounds());
1926            } catch (IllegalArgumentException | IllegalStateException e) {
1927                throw BugReport.intercept(e)
1928                               .put("graphics", graphics).put("tileSource", tileSource).put("currentZoomLevel", currentZoomLevel);
1929            }
1930        }
1931
1932        private void allocateCacheMemory() {
1933            if (memory == null) {
1934                MemoryManager manager = MemoryManager.getInstance();
1935                if (manager.isAvailable(getEstimatedCacheSize())) {
1936                    try {
1937                        memory = manager.allocateMemory("tile source layer", getEstimatedCacheSize(), Object::new);
1938                    } catch (NotEnoughMemoryException e) {
1939                        Logging.warn("Could not allocate tile source memory", e);
1940                    }
1941                }
1942            }
1943        }
1944
1945        protected long getEstimatedCacheSize() {
1946            return 4L * tileSource.getTileSize() * tileSource.getTileSize() * estimateTileCacheSize();
1947        }
1948
1949        @Override
1950        public void detachFromMapView(MapViewEvent event) {
1951            event.getMapView().removeMouseListener(adapter);
1952            MapView.removeZoomChangeListener(AbstractTileSourceLayer.this);
1953            super.detachFromMapView(event);
1954            if (memory != null) {
1955                memory.free();
1956            }
1957        }
1958    }
1959
1960    @Override
1961    public void projectionChanged(Projection oldValue, Projection newValue) {
1962        super.projectionChanged(oldValue, newValue);
1963        displaySettings.setOffsetBookmark(displaySettings.getOffsetBookmark());
1964        if (tileCache != null) {
1965            tileCache.clear();
1966        }
1967    }
1968
1969    @Override
1970    protected List<OffsetMenuEntry> getOffsetMenuEntries() {
1971        return OffsetBookmark.getBookmarks()
1972            .stream()
1973            .filter(b -> b.isUsable(this))
1974            .map(OffsetMenuBookmarkEntry::new)
1975            .collect(Collectors.toList());
1976    }
1977
1978    /**
1979     * An entry for a bookmark in the offset menu.
1980     * @author Michael Zangl
1981     */
1982    private class OffsetMenuBookmarkEntry implements OffsetMenuEntry {
1983        private final OffsetBookmark bookmark;
1984
1985        OffsetMenuBookmarkEntry(OffsetBookmark bookmark) {
1986            this.bookmark = bookmark;
1987
1988        }
1989
1990        @Override
1991        public String getLabel() {
1992            return bookmark.getName();
1993        }
1994
1995        @Override
1996        public boolean isActive() {
1997            EastNorth offset = bookmark.getDisplacement(ProjectionRegistry.getProjection());
1998            EastNorth active = getDisplaySettings().getDisplacement();
1999            return Utils.equalsEpsilon(offset.east(), active.east()) && Utils.equalsEpsilon(offset.north(), active.north());
2000        }
2001
2002        @Override
2003        public void actionPerformed() {
2004            getDisplaySettings().setOffsetBookmark(bookmark);
2005        }
2006    }
2007}