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        ProjectionBounds bounds = MainApplication.getMap().mapView.getState().getViewArea().getProjectionBounds();
904        return getTileSet(bounds, currentZoomLevel);
905    }
906
907    /**
908     * Load all visible tiles.
909     * @param force {@code true} to force loading if auto-load is disabled
910     * @since 11950
911     */
912    public void loadAllTiles(boolean force) {
913        TileSet ts = getVisibleTileSet();
914
915        // if there is more than 18 tiles on screen in any direction, do not load all tiles!
916        if (ts.tooLarge()) {
917            Logging.warn("Not downloading all tiles because there is more than 18 tiles on an axis!");
918            return;
919        }
920        ts.loadAllTiles(force);
921        invalidate();
922    }
923
924    /**
925     * Load all visible tiles in error.
926     * @param force {@code true} to force loading if auto-load is disabled
927     * @since 11950
928     */
929    public void loadAllErrorTiles(boolean force) {
930        TileSet ts = getVisibleTileSet();
931        ts.loadAllErrorTiles(force);
932        invalidate();
933    }
934
935    @Override
936    public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) {
937        boolean done = (infoflags & (ERROR | FRAMEBITS | ALLBITS)) != 0;
938        Logging.debug("imageUpdate() done: {0} calling repaint", done);
939
940        if (done) {
941            invalidate();
942        } else {
943            invalidateLater();
944        }
945        return !done;
946    }
947
948    /**
949     * Invalidate the layer at a time in the future so that the user still sees the interface responsive.
950     */
951    private void invalidateLater() {
952        GuiHelper.runInEDT(() -> {
953            if (!invalidateLaterTimer.isRunning()) {
954                invalidateLaterTimer.setRepeats(false);
955                invalidateLaterTimer.start();
956            }
957        });
958    }
959
960    private boolean imageLoaded(Image i) {
961        if (i == null)
962            return false;
963        int status = Toolkit.getDefaultToolkit().checkImage(i, -1, -1, this);
964        return (status & ALLBITS) != 0;
965    }
966
967    /**
968     * Returns the image for the given tile image is loaded.
969     * Otherwise returns  null.
970     *
971     * @param tile the Tile for which the image should be returned
972     * @return  the image of the tile or null.
973     */
974    private BufferedImage getLoadedTileImage(Tile tile) {
975        BufferedImage img = tile.getImage();
976        if (!imageLoaded(img))
977            return null;
978        return img;
979    }
980
981    /**
982     * Draw a tile image on screen.
983     * @param g the Graphics2D
984     * @param toDrawImg tile image
985     * @param anchorImage tile anchor in image coordinates
986     * @param anchorScreen tile anchor in screen coordinates
987     * @param clip clipping region in screen coordinates (can be null)
988     */
989    private void drawImageInside(Graphics2D g, BufferedImage toDrawImg, TileAnchor anchorImage, TileAnchor anchorScreen, Shape clip) {
990        AffineTransform imageToScreen = anchorImage.convert(anchorScreen);
991        Point2D screen0 = imageToScreen.transform(new Point.Double(0, 0), null);
992        Point2D screen1 = imageToScreen.transform(new Point.Double(
993                toDrawImg.getWidth(), toDrawImg.getHeight()), null);
994
995        Shape oldClip = null;
996        if (clip != null) {
997            oldClip = g.getClip();
998            g.clip(clip);
999        }
1000        g.drawImage(toDrawImg, (int) Math.round(screen0.getX()), (int) Math.round(screen0.getY()),
1001                (int) Math.round(screen1.getX()) - (int) Math.round(screen0.getX()),
1002                (int) Math.round(screen1.getY()) - (int) Math.round(screen0.getY()), this);
1003        if (clip != null) {
1004            g.setClip(oldClip);
1005        }
1006    }
1007
1008    private List<Tile> paintTileImages(Graphics2D g, TileSet ts) {
1009        Object paintMutex = new Object();
1010        List<TilePosition> missed = Collections.synchronizedList(new ArrayList<>());
1011        ts.visitTiles(tile -> {
1012            boolean miss = false;
1013            BufferedImage img = null;
1014            TileAnchor anchorImage = null;
1015            if (!tile.isLoaded() || tile.hasError()) {
1016                miss = true;
1017            } else {
1018                synchronized (tile) {
1019                    img = getLoadedTileImage(tile);
1020                    anchorImage = getAnchor(tile, img);
1021                }
1022                if (img == null || anchorImage == null) {
1023                    miss = true;
1024                }
1025            }
1026            if (miss) {
1027                missed.add(new TilePosition(tile));
1028                return;
1029            }
1030
1031            img = applyImageProcessors(img);
1032
1033            TileAnchor anchorScreen = coordinateConverter.getScreenAnchorForTile(tile);
1034            synchronized (paintMutex) {
1035                //cannot paint in parallel
1036                drawImageInside(g, img, anchorImage, anchorScreen, null);
1037            }
1038            MapView mapView = MainApplication.getMap().mapView;
1039            if (tile instanceof ReprojectionTile && ((ReprojectionTile) tile).needsUpdate(mapView.getScale())) {
1040                // This means we have a reprojected tile in memory cache, but not at
1041                // current scale. Generally, the positioning of the tile will still
1042                // be correct, but for best image quality, the tile should be
1043                // reprojected to the target scale. The original tile image should
1044                // still be in disk cache, so this is fairly cheap.
1045                ((ReprojectionTile) tile).invalidate();
1046                loadTile(tile, false);
1047            }
1048
1049        }, missed::add);
1050
1051        return missed.stream().map(this::getOrCreateTile).collect(Collectors.toList());
1052    }
1053
1054    // This function is called for several zoom levels, not just the current one.
1055    // It should not trigger any tiles to be downloaded.
1056    // It should also avoid polluting the tile cache with any tiles since these tiles are not mandatory.
1057    //
1058    // The "border" tile tells us the boundaries of where we may drawn.
1059    // It will not be from the zoom level that is being drawn currently.
1060    // If drawing the displayZoomLevel, border is null and we draw the entire tile set.
1061    private List<Tile> paintTileImages(Graphics2D g, TileSet ts, int zoom, Tile border) {
1062        if (zoom <= 0) return Collections.emptyList();
1063        Shape borderClip = coordinateConverter.getTileShapeScreen(border);
1064        List<Tile> missedTiles = new LinkedList<>();
1065        // The callers of this code *require* that we return any tiles that we do not draw in missedTiles.
1066        // ts.allExistingTiles() by default will only return already-existing tiles.
1067        // However, we need to return *all* tiles to the callers, so force creation here.
1068        for (Tile tile : ts.allTilesCreate()) {
1069            boolean miss = false;
1070            BufferedImage img = null;
1071            TileAnchor anchorImage = null;
1072            if (!tile.isLoaded() || tile.hasError()) {
1073                miss = true;
1074            } else {
1075                synchronized (tile) {
1076                    img = getLoadedTileImage(tile);
1077                    anchorImage = getAnchor(tile, img);
1078                }
1079
1080                if (img == null || anchorImage == null) {
1081                    miss = true;
1082                }
1083            }
1084            if (miss) {
1085                missedTiles.add(tile);
1086                continue;
1087            }
1088
1089            // applying all filters to this layer
1090            img = applyImageProcessors(img);
1091
1092            Shape clip;
1093            if (tileSource.isInside(tile, border)) {
1094                clip = null;
1095            } else if (tileSource.isInside(border, tile)) {
1096                clip = borderClip;
1097            } else {
1098                continue;
1099            }
1100            TileAnchor anchorScreen = coordinateConverter.getScreenAnchorForTile(tile);
1101            drawImageInside(g, img, anchorImage, anchorScreen, clip);
1102        }
1103        return missedTiles;
1104    }
1105
1106    private static TileAnchor getAnchor(Tile tile, BufferedImage image) {
1107        if (tile instanceof ReprojectionTile) {
1108            return ((ReprojectionTile) tile).getAnchor();
1109        } else if (image != null) {
1110            return new TileAnchor(new Point.Double(0, 0), new Point.Double(image.getWidth(), image.getHeight()));
1111        } else {
1112            return null;
1113        }
1114    }
1115
1116    private void myDrawString(Graphics g, String text, int x, int y) {
1117        Color oldColor = g.getColor();
1118        String textToDraw = text;
1119        if (g.getFontMetrics().stringWidth(text) > tileSource.getTileSize()) {
1120            // text longer than tile size, split it
1121            StringBuilder line = new StringBuilder();
1122            StringBuilder ret = new StringBuilder();
1123            for (String s: text.split(" ")) {
1124                if (g.getFontMetrics().stringWidth(line.toString() + s) > tileSource.getTileSize()) {
1125                    ret.append(line).append('\n');
1126                    line.setLength(0);
1127                }
1128                line.append(s).append(' ');
1129            }
1130            ret.append(line);
1131            textToDraw = ret.toString();
1132        }
1133        int offset = 0;
1134        for (String s: textToDraw.split("\n")) {
1135            g.setColor(Color.black);
1136            g.drawString(s, x + 1, y + offset + 1);
1137            g.setColor(oldColor);
1138            g.drawString(s, x, y + offset);
1139            offset += g.getFontMetrics().getHeight() + 3;
1140        }
1141    }
1142
1143    private void paintTileText(Tile tile, Graphics2D g) {
1144        if (tile == null) {
1145            return;
1146        }
1147        Point2D p = coordinateConverter.getPixelForTile(tile);
1148        int fontHeight = g.getFontMetrics().getHeight();
1149        int x = (int) p.getX();
1150        int y = (int) p.getY();
1151        int texty = y + 2 + fontHeight;
1152
1153        /*if (PROP_DRAW_DEBUG.get()) {
1154            myDrawString(g, "x=" + t.getXtile() + " y=" + t.getYtile() + " z=" + zoom + "", p.x + 2, texty);
1155            texty += 1 + fontHeight;
1156            if ((t.getXtile() % 32 == 0) && (t.getYtile() % 32 == 0)) {
1157                myDrawString(g, "x=" + t.getXtile() / 32 + " y=" + t.getYtile() / 32 + " z=7", p.x + 2, texty);
1158                texty += 1 + fontHeight;
1159            }
1160        }
1161
1162        String tileStatus = tile.getStatus();
1163        if (!tile.isLoaded() && PROP_DRAW_DEBUG.get()) {
1164            myDrawString(g, tr("image " + tileStatus), p.x + 2, texty);
1165            texty += 1 + fontHeight;
1166        }*/
1167
1168        if (tile.hasError() && getDisplaySettings().isShowErrors()) {
1169            String errorMessage = tr(tile.getErrorMessage());
1170            if (errorMessage != null) {
1171                if (!errorMessage.startsWith("Error") && !errorMessage.startsWith(tr("Error"))) {
1172                    errorMessage = tr("Error") + ": " + errorMessage;
1173                }
1174                myDrawString(g, errorMessage, x + 2, texty);
1175            }
1176            //texty += 1 + fontHeight;
1177        }
1178
1179        if (Logging.isDebugEnabled()) {
1180            // draw tile outline in semi-transparent red
1181            g.setColor(new Color(255, 0, 0, 50));
1182            g.draw(coordinateConverter.getTileShapeScreen(tile));
1183        }
1184    }
1185
1186    private LatLon getShiftedLatLon(EastNorth en) {
1187        return coordinateConverter.getProjecting().eastNorth2latlonClamped(en);
1188    }
1189
1190    private ICoordinate getShiftedCoord(EastNorth en) {
1191        return CoordinateConversion.llToCoor(getShiftedLatLon(en));
1192    }
1193
1194    private final TileSet nullTileSet = new TileSet();
1195
1196    protected class TileSet extends TileRange {
1197
1198        private volatile TileSetInfo info;
1199
1200        protected TileSet(TileXY t1, TileXY t2, int zoom) {
1201            super(t1, t2, zoom);
1202            sanitize();
1203        }
1204
1205        protected TileSet(TileRange range) {
1206            super(range);
1207            sanitize();
1208        }
1209
1210        /**
1211         * null tile set
1212         */
1213        private TileSet() {
1214            // default
1215        }
1216
1217        protected void sanitize() {
1218            minX = Utils.clamp(minX, tileSource.getTileXMin(zoom), tileSource.getTileXMax(zoom));
1219            maxX = Utils.clamp(maxX, tileSource.getTileXMin(zoom), tileSource.getTileXMax(zoom));
1220            minY = Utils.clamp(minY, tileSource.getTileYMin(zoom), tileSource.getTileYMax(zoom));
1221            maxY = Utils.clamp(maxY, tileSource.getTileYMin(zoom), tileSource.getTileYMax(zoom));
1222        }
1223
1224        private boolean tooSmall() {
1225            return this.tilesSpanned() < 2.1;
1226        }
1227
1228        private boolean tooLarge() {
1229            return insane() || this.tilesSpanned() > 20;
1230        }
1231
1232        private boolean insane() {
1233            return tileCache == null || size() > tileCache.getCacheSize();
1234        }
1235
1236        /**
1237         * Get all tiles represented by this TileSet that are already in the tileCache.
1238         * @return all tiles represented by this TileSet that are already in the tileCache
1239         */
1240        private List<Tile> allExistingTiles() {
1241            return allTiles(AbstractTileSourceLayer.this::getTile);
1242        }
1243
1244        private List<Tile> allTilesCreate() {
1245            return allTiles(AbstractTileSourceLayer.this::getOrCreateTile);
1246        }
1247
1248        private List<Tile> allTiles(Function<TilePosition, Tile> mapper) {
1249            return tilePositions().map(mapper).filter(Objects::nonNull).collect(Collectors.toList());
1250        }
1251
1252        /**
1253         * Gets a stream of all tile positions in this set
1254         * @return A stream of all positions
1255         */
1256        public Stream<TilePosition> tilePositions() {
1257            if (zoom == 0 || this.insane()) {
1258                return Stream.empty(); // Tileset is either empty or too large
1259            } else {
1260                return IntStream.rangeClosed(minX, maxX).mapToObj(
1261                        x -> IntStream.rangeClosed(minY, maxY).mapToObj(y -> new TilePosition(x, y, zoom))
1262                        ).flatMap(Function.identity());
1263            }
1264        }
1265
1266        private List<Tile> allLoadedTiles() {
1267            return allExistingTiles().stream().filter(Tile::isLoaded).collect(Collectors.toList());
1268        }
1269
1270        /**
1271         * @return comparator, that sorts the tiles from the center to the edge of the current screen
1272         */
1273        private Comparator<Tile> getTileDistanceComparator() {
1274            final int centerX = (int) Math.ceil((minX + maxX) / 2d);
1275            final int centerY = (int) Math.ceil((minY + maxY) / 2d);
1276            return Comparator.comparingInt(t -> Math.abs(t.getXtile() - centerX) + Math.abs(t.getYtile() - centerY));
1277        }
1278
1279        private void loadAllTiles(boolean force) {
1280            if (!getDisplaySettings().isAutoLoad() && !force)
1281                return;
1282            List<Tile> allTiles = allTilesCreate();
1283            allTiles.sort(getTileDistanceComparator());
1284            for (Tile t : allTiles) {
1285                loadTile(t, force);
1286            }
1287        }
1288
1289        private void loadAllErrorTiles(boolean force) {
1290            if (!getDisplaySettings().isAutoLoad() && !force)
1291                return;
1292            for (Tile t : this.allTilesCreate()) {
1293                if (t.hasError()) {
1294                    tileLoader.createTileLoaderJob(t).submit(force);
1295                }
1296            }
1297        }
1298
1299        /**
1300         * Call the given paint method for all tiles in this tile set.<p>
1301         * Uses a parallel stream.
1302         * @param visitor A visitor to call for each tile.
1303         * @param missed a consumer to call for each missed tile.
1304         */
1305        public void visitTiles(Consumer<Tile> visitor, Consumer<TilePosition> missed) {
1306            tilePositions().parallel().forEach(tp -> visitTilePosition(visitor, tp, missed));
1307        }
1308
1309        private void visitTilePosition(Consumer<Tile> visitor, TilePosition tp, Consumer<TilePosition> missed) {
1310            Tile tile = getTile(tp);
1311            if (tile == null) {
1312                missed.accept(tp);
1313            } else {
1314                visitor.accept(tile);
1315            }
1316        }
1317
1318        /**
1319         * Check if there is any tile fully loaded without error.
1320         * @return true if there is any tile fully loaded without error
1321         */
1322        public boolean hasVisibleTiles() {
1323            return getTileSetInfo().hasVisibleTiles;
1324        }
1325
1326        /**
1327         * Check if there there is a tile that is overzoomed.
1328         * <p>
1329         * I.e. the server response for one tile was "there is no tile here".
1330         * This usually happens when zoomed in too much. The limit depends on
1331         * the region, so at the edge of such a region, some tiles may be
1332         * available and some not.
1333         * @return true if there there is a tile that is overzoomed
1334         */
1335        public boolean hasOverzoomedTiles() {
1336            return getTileSetInfo().hasOverzoomedTiles;
1337        }
1338
1339        /**
1340         * Check if there are tiles still loading.
1341         * <p>
1342         * This is the case if there is a tile not yet in the cache, or in the
1343         * cache but marked as loading ({@link Tile#isLoading()}.
1344         * @return true if there are tiles still loading
1345         */
1346        public boolean hasLoadingTiles() {
1347            return getTileSetInfo().hasLoadingTiles;
1348        }
1349
1350        /**
1351         * Check if all tiles in the range are fully loaded.
1352         * <p>
1353         * A tile is considered to be fully loaded even if the result of loading
1354         * the tile was an error.
1355         * @return true if all tiles in the range are fully loaded
1356         */
1357        public boolean hasAllLoadedTiles() {
1358            return getTileSetInfo().hasAllLoadedTiles;
1359        }
1360
1361        private TileSetInfo getTileSetInfo() {
1362            if (info == null) {
1363                synchronized (this) {
1364                    if (info == null) {
1365                        List<Tile> allTiles = this.allExistingTiles();
1366                        TileSetInfo newInfo = new TileSetInfo();
1367                        newInfo.hasLoadingTiles = allTiles.size() < this.size();
1368                        newInfo.hasAllLoadedTiles = true;
1369                        for (Tile t : allTiles) {
1370                            if ("no-tile".equals(t.getValue("tile-info"))) {
1371                                newInfo.hasOverzoomedTiles = true;
1372                            }
1373                            if (t.isLoaded()) {
1374                                if (!t.hasError()) {
1375                                    newInfo.hasVisibleTiles = true;
1376                                }
1377                            } else {
1378                                newInfo.hasAllLoadedTiles = false;
1379                                if (t.isLoading()) {
1380                                    newInfo.hasLoadingTiles = true;
1381                                }
1382                            }
1383                        }
1384                        info = newInfo;
1385                    }
1386                }
1387            }
1388            return info;
1389        }
1390
1391        @Override
1392        public String toString() {
1393            return getClass().getName() + ": zoom: " + zoom + " X(" + minX + ", " + maxX + ") Y(" + minY + ", " + maxY + ") size: " + size();
1394        }
1395    }
1396
1397    /**
1398     * Data container to hold information about a {@code TileSet} class.
1399     */
1400    private static class TileSetInfo {
1401        boolean hasVisibleTiles;
1402        boolean hasOverzoomedTiles;
1403        boolean hasLoadingTiles;
1404        boolean hasAllLoadedTiles;
1405    }
1406
1407    /**
1408     * Create a TileSet by EastNorth bbox taking a layer shift in account
1409     * @param bounds the EastNorth bounds
1410     * @param zoom zoom level
1411     * @return the tile set
1412     */
1413    protected TileSet getTileSet(ProjectionBounds bounds, int zoom) {
1414        if (zoom == 0)
1415            return new TileSet();
1416        TileXY t1, t2;
1417        IProjected topLeftUnshifted = coordinateConverter.shiftDisplayToServer(bounds.getMin());
1418        IProjected botRightUnshifted = coordinateConverter.shiftDisplayToServer(bounds.getMax());
1419        if (coordinateConverter.requiresReprojection()) {
1420            Projection projServer = Projections.getProjectionByCode(tileSource.getServerCRS());
1421            if (projServer == null) {
1422                throw new IllegalStateException(tileSource.toString());
1423            }
1424            ProjectionBounds projBounds = new ProjectionBounds(
1425                    CoordinateConversion.projToEn(topLeftUnshifted),
1426                    CoordinateConversion.projToEn(botRightUnshifted));
1427            ProjectionBounds bbox = projServer.getEastNorthBoundsBox(projBounds, ProjectionRegistry.getProjection());
1428            t1 = tileSource.projectedToTileXY(CoordinateConversion.enToProj(bbox.getMin()), zoom);
1429            t2 = tileSource.projectedToTileXY(CoordinateConversion.enToProj(bbox.getMax()), zoom);
1430        } else {
1431            t1 = tileSource.projectedToTileXY(topLeftUnshifted, zoom);
1432            t2 = tileSource.projectedToTileXY(botRightUnshifted, zoom);
1433        }
1434        return new TileSet(t1, t2, zoom);
1435    }
1436
1437    private class DeepTileSet {
1438        private final ProjectionBounds bounds;
1439        private final int minZoom, maxZoom;
1440        private final TileSet[] tileSets;
1441
1442        @SuppressWarnings("unchecked")
1443        DeepTileSet(ProjectionBounds bounds, int minZoom, int maxZoom) {
1444            this.bounds = bounds;
1445            this.minZoom = minZoom;
1446            this.maxZoom = maxZoom;
1447            if (minZoom > maxZoom) {
1448                throw new IllegalArgumentException(minZoom + " > " + maxZoom);
1449            }
1450            this.tileSets = new AbstractTileSourceLayer.TileSet[maxZoom - minZoom + 1];
1451        }
1452
1453        public TileSet getTileSet(int zoom) {
1454            if (zoom < minZoom)
1455                return nullTileSet;
1456            synchronized (tileSets) {
1457                TileSet ts = tileSets[zoom-minZoom];
1458                if (ts == null) {
1459                    ts = AbstractTileSourceLayer.this.getTileSet(bounds, zoom);
1460                    tileSets[zoom-minZoom] = ts;
1461                }
1462                return ts;
1463            }
1464        }
1465    }
1466
1467    @Override
1468    public void paint(Graphics2D g, MapView mv, Bounds bounds) {
1469        // old and unused.
1470    }
1471
1472    private void drawInViewArea(Graphics2D g, MapView mv, ProjectionBounds pb) {
1473        int zoom = currentZoomLevel;
1474        if (getDisplaySettings().isAutoZoom()) {
1475            zoom = getBestZoom();
1476        }
1477
1478        DeepTileSet dts = new DeepTileSet(pb, getMinZoomLvl(), zoom);
1479
1480        int displayZoomLevel = zoom;
1481
1482        boolean noTilesAtZoom = false;
1483        if (getDisplaySettings().isAutoZoom() && getDisplaySettings().isAutoLoad()) {
1484            // Auto-detection of tilesource maxzoom (currently fully works only for Bing)
1485            TileSet ts0 = dts.getTileSet(zoom);
1486            if (!ts0.hasVisibleTiles() && (!ts0.hasLoadingTiles() || ts0.hasOverzoomedTiles())) {
1487                noTilesAtZoom = true;
1488            }
1489            // Find highest zoom level with at least one visible tile
1490            for (int tmpZoom = zoom; tmpZoom > dts.minZoom; tmpZoom--) {
1491                if (dts.getTileSet(tmpZoom).hasVisibleTiles()) {
1492                    displayZoomLevel = tmpZoom;
1493                    break;
1494                }
1495            }
1496            // Do binary search between currentZoomLevel and displayZoomLevel
1497            while (zoom > displayZoomLevel && !ts0.hasVisibleTiles() && ts0.hasOverzoomedTiles()) {
1498                zoom = (zoom + displayZoomLevel)/2;
1499                ts0 = dts.getTileSet(zoom);
1500            }
1501
1502            setZoomLevel(zoom, false);
1503
1504            // If all tiles at displayZoomLevel is loaded, load all tiles at next zoom level
1505            // to make sure there're really no more zoom levels
1506            // loading is done in the next if section
1507            if (zoom == displayZoomLevel && !ts0.hasLoadingTiles() && zoom < dts.maxZoom) {
1508                zoom++;
1509                ts0 = dts.getTileSet(zoom);
1510            }
1511            // When we have overzoomed tiles and all tiles at current zoomlevel is loaded,
1512            // load tiles at previovus zoomlevels until we have all tiles on screen is loaded.
1513            // loading is done in the next if section
1514            while (zoom > dts.minZoom && ts0.hasOverzoomedTiles() && !ts0.hasLoadingTiles()) {
1515                zoom--;
1516                ts0 = dts.getTileSet(zoom);
1517            }
1518        } else if (getDisplaySettings().isAutoZoom()) {
1519            setZoomLevel(zoom, false);
1520        }
1521        TileSet ts = dts.getTileSet(zoom);
1522
1523        // Too many tiles... refuse to download
1524        if (!ts.tooLarge()) {
1525            // try to load tiles from desired zoom level, no matter what we will show (for example, tiles from previous zoom level
1526            // on zoom in)
1527            ts.loadAllTiles(false);
1528        }
1529
1530        if (displayZoomLevel != zoom) {
1531            ts = dts.getTileSet(displayZoomLevel);
1532            if (!dts.getTileSet(displayZoomLevel).hasAllLoadedTiles() && displayZoomLevel < zoom) {
1533                // if we are showing tiles from lower zoom level, ensure that all tiles are loaded as they are few,
1534                // and should not trash the tile cache
1535                // This is especially needed when dts.getTileSet(zoom).tooLarge() is true and we are not loading tiles
1536                ts.loadAllTiles(false);
1537            }
1538        }
1539
1540        g.setColor(Color.DARK_GRAY);
1541
1542        List<Tile> missedTiles = this.paintTileImages(g, ts);
1543        int[] otherZooms = {1, 2, -1, -2, -3, -4, -5};
1544        for (int zoomOffset : otherZooms) {
1545            if (!getDisplaySettings().isAutoZoom()) {
1546                break;
1547            }
1548            int newzoom = displayZoomLevel + zoomOffset;
1549            if (newzoom < getMinZoomLvl() || newzoom > getMaxZoomLvl()) {
1550                continue;
1551            }
1552            if (missedTiles.isEmpty()) {
1553                break;
1554            }
1555            List<Tile> newlyMissedTiles = new LinkedList<>();
1556            for (Tile missed : missedTiles) {
1557                if (zoomOffset > 0 && "no-tile".equals(missed.getValue("tile-info"))) {
1558                    // Don't try to paint from higher zoom levels when tile is overzoomed
1559                    newlyMissedTiles.add(missed);
1560                    continue;
1561                }
1562                TileSet ts2 = new TileSet(tileSource.getCoveringTileRange(missed, newzoom));
1563                // Instantiating large TileSets is expensive. If there are no loaded tiles, don't bother even trying.
1564                if (ts2.allLoadedTiles().isEmpty()) {
1565                    if (zoomOffset > 0) {
1566                        newlyMissedTiles.add(missed);
1567                        continue;
1568                    } else {
1569                        /*
1570                         *  We have negative zoom offset. Try to load tiles from lower zoom levels, as they may be not present
1571                         *  in tile cache (e.g. when user panned the map or opened layer above zoom level, for which tiles are present.
1572                         *  This will ensure, that tileCache is populated with tiles from lower zoom levels so it will be possible to
1573                         *  use them to paint overzoomed tiles.
1574                         *  See: #14562
1575                         */
1576                        ts2.loadAllTiles(false);
1577                    }
1578                }
1579                if (ts2.tooLarge()) {
1580                    continue;
1581                }
1582                newlyMissedTiles.addAll(this.paintTileImages(g, ts2, newzoom, missed));
1583            }
1584            missedTiles = newlyMissedTiles;
1585        }
1586        if (Logging.isDebugEnabled() && !missedTiles.isEmpty()) {
1587            Logging.debug("still missed {0} in the end", missedTiles.size());
1588        }
1589        g.setColor(Color.red);
1590        g.setFont(InfoFont);
1591
1592        // The current zoom tileset should have all of its tiles due to the loadAllTiles(), unless it to tooLarge()
1593        for (Tile t : ts.allExistingTiles()) {
1594            this.paintTileText(t, g);
1595        }
1596
1597        EastNorth min = pb.getMin();
1598        EastNorth max = pb.getMax();
1599        attribution.paintAttribution(g, mv.getWidth(), mv.getHeight(), getShiftedCoord(min), getShiftedCoord(max),
1600                displayZoomLevel, this);
1601
1602        g.setColor(Color.lightGray);
1603
1604        if (ts.insane()) {
1605            myDrawString(g, tr("zoom in to load any tiles"), 120, 120);
1606        } else if (ts.tooLarge()) {
1607            myDrawString(g, tr("zoom in to load more tiles"), 120, 120);
1608        } else if (!getDisplaySettings().isAutoZoom() && ts.tooSmall()) {
1609            myDrawString(g, tr("increase tiles zoom level (change resolution) to see more detail"), 120, 120);
1610        }
1611        if (noTilesAtZoom) {
1612            myDrawString(g, tr("No tiles at this zoom level"), 120, 120);
1613        }
1614        if (Logging.isDebugEnabled()) {
1615            myDrawString(g, tr("Current zoom: {0}", currentZoomLevel), 50, 140);
1616            myDrawString(g, tr("Display zoom: {0}", displayZoomLevel), 50, 155);
1617            myDrawString(g, tr("Pixel scale: {0}", getScaleFactor(currentZoomLevel)), 50, 170);
1618            myDrawString(g, tr("Best zoom: {0}", getBestZoom()), 50, 185);
1619            myDrawString(g, tr("Estimated cache size: {0}", estimateTileCacheSize()), 50, 200);
1620            if (tileLoader instanceof TMSCachedTileLoader) {
1621                int offset = 200;
1622                for (String part: ((TMSCachedTileLoader) tileLoader).getStats().split("\n")) {
1623                    offset += 15;
1624                    myDrawString(g, tr("Cache stats: {0}", part), 50, offset);
1625                }
1626            }
1627        }
1628    }
1629
1630    /**
1631     * Returns tile for a pixel position.<p>
1632     * This isn't very efficient, but it is only used when the user right-clicks on the map.
1633     * @param px pixel X coordinate
1634     * @param py pixel Y coordinate
1635     * @return Tile at pixel position
1636     */
1637    private Tile getTileForPixelpos(int px, int py) {
1638        Logging.debug("getTileForPixelpos({0}, {1})", px, py);
1639        TileXY xy = coordinateConverter.getTileforPixel(px, py, currentZoomLevel);
1640        return getTile(xy.getXIndex(), xy.getYIndex(), currentZoomLevel);
1641    }
1642
1643    /**
1644     * Class to store a menu action and the class it belongs to.
1645     */
1646    private static class MenuAddition {
1647        final Action addition;
1648        @SuppressWarnings("rawtypes")
1649        final Class<? extends AbstractTileSourceLayer> clazz;
1650
1651        @SuppressWarnings("rawtypes")
1652        MenuAddition(Action addition, Class<? extends AbstractTileSourceLayer> clazz) {
1653            this.addition = addition;
1654            this.clazz = clazz;
1655        }
1656    }
1657
1658    /**
1659     * Register an additional layer context menu entry.
1660     *
1661     * @param addition additional menu action
1662     * @since 11197
1663     */
1664    public static void registerMenuAddition(Action addition) {
1665        menuAdditions.add(new MenuAddition(addition, AbstractTileSourceLayer.class));
1666    }
1667
1668    /**
1669     * Register an additional layer context menu entry for a imagery layer
1670     * class.  The menu entry is valid for the specified class and subclasses
1671     * thereof only.
1672     * <p>
1673     * Example:
1674     * <pre>
1675     * TMSLayer.registerMenuAddition(new TMSSpecificAction(), TMSLayer.class);
1676     * </pre>
1677     *
1678     * @param addition additional menu action
1679     * @param clazz class the menu action is registered for
1680     * @since 11197
1681     */
1682    public static void registerMenuAddition(Action addition,
1683                                            Class<? extends AbstractTileSourceLayer<?>> clazz) {
1684        menuAdditions.add(new MenuAddition(addition, clazz));
1685    }
1686
1687    /**
1688     * Prepare list of additional layer context menu entries.  The list is
1689     * empty if there are no additional menu entries.
1690     *
1691     * @return list of additional layer context menu entries
1692     */
1693    private List<Action> getMenuAdditions() {
1694        final LinkedList<Action> menuAdds = new LinkedList<>();
1695        for (MenuAddition menuAdd: menuAdditions) {
1696            if (menuAdd.clazz.isInstance(this)) {
1697                menuAdds.add(menuAdd.addition);
1698            }
1699        }
1700        if (!menuAdds.isEmpty()) {
1701            menuAdds.addFirst(SeparatorLayerAction.INSTANCE);
1702        }
1703        return menuAdds;
1704    }
1705
1706    @Override
1707    public Action[] getMenuEntries() {
1708        ArrayList<Action> actions = new ArrayList<>();
1709        actions.addAll(Arrays.asList(getLayerListEntries()));
1710        actions.addAll(Arrays.asList(getCommonEntries()));
1711        actions.addAll(getMenuAdditions());
1712        actions.add(SeparatorLayerAction.INSTANCE);
1713        actions.add(new LayerListPopup.InfoAction(this));
1714        return actions.toArray(new Action[0]);
1715    }
1716
1717    /**
1718     * Returns the contextual menu entries in layer list dialog.
1719     * @return the contextual menu entries in layer list dialog
1720     */
1721    public Action[] getLayerListEntries() {
1722        return new Action[] {
1723            LayerListDialog.getInstance().createActivateLayerAction(this),
1724            LayerListDialog.getInstance().createShowHideLayerAction(),
1725            LayerListDialog.getInstance().createDeleteLayerAction(),
1726            SeparatorLayerAction.INSTANCE,
1727            // color,
1728            new OffsetAction(),
1729            new RenameLayerAction(this.getAssociatedFile(), this),
1730            SeparatorLayerAction.INSTANCE
1731        };
1732    }
1733
1734    /**
1735     * Returns the common menu entries.
1736     * @return the common menu entries
1737     */
1738    public Action[] getCommonEntries() {
1739        return new Action[] {
1740            new AutoLoadTilesAction(this),
1741            new AutoZoomAction(this),
1742            new ShowErrorsAction(this),
1743            new IncreaseZoomAction(this),
1744            new DecreaseZoomAction(this),
1745            new ZoomToBestAction(this),
1746            new ZoomToNativeLevelAction(this),
1747            new FlushTileCacheAction(this),
1748            new LoadErroneousTilesAction(this),
1749            new LoadAllTilesAction(this)
1750        };
1751    }
1752
1753    @Override
1754    public String getToolTipText() {
1755        if (getDisplaySettings().isAutoLoad()) {
1756            return tr("{0} ({1}), automatically downloading in zoom {2}", this.getClass().getSimpleName(), getName(), currentZoomLevel);
1757        } else {
1758            return tr("{0} ({1}), downloading in zoom {2}", this.getClass().getSimpleName(), getName(), currentZoomLevel);
1759        }
1760    }
1761
1762    @Override
1763    public void visitBoundingBox(BoundingXYVisitor v) {
1764    }
1765
1766    /**
1767     * Task responsible for precaching imagery along the gpx track
1768     *
1769     */
1770    public class PrecacheTask implements TileLoaderListener {
1771        private final ProgressMonitor progressMonitor;
1772        private final int totalCount;
1773        private final AtomicInteger processedCount = new AtomicInteger(0);
1774        private final TileLoader tileLoader;
1775        private final Set<Tile> requestedTiles;
1776
1777        /**
1778         * Constructs a new {@code PrecacheTask}.
1779         * @param progressMonitor that will be notified about progess of the task
1780         * @param bufferY buffer Y in degrees around which to download tiles
1781         * @param bufferX buffer X in degrees around which to download tiles
1782         * @param points list of points along which to download
1783         */
1784        public PrecacheTask(ProgressMonitor progressMonitor, List<LatLon> points, double bufferX, double bufferY) {
1785            this.progressMonitor = progressMonitor;
1786            this.tileLoader = getTileLoaderFactory().makeTileLoader(this, getHeaders(tileSource), minimumTileExpire);
1787            if (this.tileLoader instanceof TMSCachedTileLoader) {
1788                ((TMSCachedTileLoader) this.tileLoader).setDownloadExecutor(
1789                        TMSCachedTileLoader.getNewThreadPoolExecutor("Precache downloader"));
1790            }
1791            requestedTiles = new ConcurrentSkipListSet<>(
1792                    (o1, o2) -> String.CASE_INSENSITIVE_ORDER.compare(o1.getKey(), o2.getKey()));
1793            for (LatLon point: points) {
1794                TileXY minTile = tileSource.latLonToTileXY(point.lat() - bufferY, point.lon() - bufferX, currentZoomLevel);
1795                TileXY curTile = tileSource.latLonToTileXY(CoordinateConversion.llToCoor(point), currentZoomLevel);
1796                TileXY maxTile = tileSource.latLonToTileXY(point.lat() + bufferY, point.lon() + bufferX, currentZoomLevel);
1797
1798                // take at least one tile of buffer
1799                int minY = Math.min(curTile.getYIndex() - 1, minTile.getYIndex());
1800                int maxY = Math.max(curTile.getYIndex() + 1, maxTile.getYIndex());
1801                int minX = Math.min(curTile.getXIndex() - 1, minTile.getXIndex());
1802                int maxX = Math.max(curTile.getXIndex() + 1, maxTile.getXIndex());
1803
1804                for (int x = minX; x <= maxX; x++) {
1805                    for (int y = minY; y <= maxY; y++) {
1806                        requestedTiles.add(new Tile(tileSource, x, y, currentZoomLevel));
1807                    }
1808                }
1809            }
1810
1811            this.totalCount = requestedTiles.size();
1812            this.progressMonitor.setTicksCount(requestedTiles.size());
1813
1814        }
1815
1816        /**
1817         * @return true, if all is done
1818         */
1819        public boolean isFinished() {
1820            return processedCount.get() >= totalCount;
1821        }
1822
1823        /**
1824         * @return total number of tiles to download
1825         */
1826        public int getTotalCount() {
1827            return totalCount;
1828        }
1829
1830        /**
1831         * cancel the task
1832         */
1833        public void cancel() {
1834            if (tileLoader instanceof TMSCachedTileLoader) {
1835                ((TMSCachedTileLoader) tileLoader).cancelOutstandingTasks();
1836            }
1837        }
1838
1839        @Override
1840        public void tileLoadingFinished(Tile tile, boolean success) {
1841            int processed = this.processedCount.incrementAndGet();
1842            if (success) {
1843                synchronized (progressMonitor) {
1844                    if (!this.progressMonitor.isCanceled()) {
1845                        this.progressMonitor.worked(1);
1846                        this.progressMonitor.setCustomText(tr("Downloaded {0}/{1} tiles", processed, totalCount));
1847                    }
1848                }
1849            } else {
1850                Logging.warn("Tile loading failure: " + tile + " - " + tile.getErrorMessage());
1851            }
1852        }
1853
1854        /**
1855         * @return tile loader that is used to load the tiles
1856         */
1857        public TileLoader getTileLoader() {
1858            return tileLoader;
1859        }
1860
1861        /**
1862         * Execute the download
1863         */
1864        public void run() {
1865            TileLoader loader = getTileLoader();
1866            for (Tile t: requestedTiles) {
1867                if (!progressMonitor.isCanceled()) {
1868                    loader.createTileLoaderJob(t).submit();
1869                }
1870            }
1871
1872        }
1873    }
1874
1875    /**
1876     * Calculates tiles, that needs to be downloaded to cache, gets a current tile loader and creates a task to download
1877     * all of the tiles. Buffer contains at least one tile.
1878     *
1879     * To prevent accidental clear of the queue, new download executor is created with separate queue
1880     *
1881     * @param progressMonitor progress monitor for download task
1882     * @param points lat/lon coordinates to download
1883     * @param bufferX how many units in current Coordinate Reference System to cover in X axis in both sides
1884     * @param bufferY how many units in current Coordinate Reference System to cover in Y axis in both sides
1885     * @return precache task representing download task
1886     */
1887    public AbstractTileSourceLayer<T>.PrecacheTask getDownloadAreaToCacheTask(final ProgressMonitor progressMonitor, List<LatLon> points,
1888            double bufferX, double bufferY) {
1889        return new PrecacheTask(progressMonitor, points, bufferX, bufferY);
1890    }
1891
1892    @Override
1893    public boolean isSavable() {
1894        return true; // With WMSLayerExporter
1895    }
1896
1897    @Override
1898    public File createAndOpenSaveFileChooser() {
1899        return SaveActionBase.createAndOpenSaveFileChooser(tr("Save WMS file"), WMSLayerImporter.FILE_FILTER);
1900    }
1901
1902    @Override
1903    public synchronized void destroy() {
1904        super.destroy();
1905        MapView.removeZoomChangeListener(this);
1906        adjustAction.destroy();
1907    }
1908
1909    private class TileSourcePainter extends CompatibilityModeLayerPainter {
1910        /** The memory handle that will hold our tile source. */
1911        private MemoryHandle<?> memory;
1912
1913        @Override
1914        public void paint(MapViewGraphics graphics) {
1915            allocateCacheMemory();
1916            if (memory != null) {
1917                doPaint(graphics);
1918            }
1919        }
1920
1921        private void doPaint(MapViewGraphics graphics) {
1922            try {
1923                drawInViewArea(graphics.getDefaultGraphics(), graphics.getMapView(), graphics.getClipBounds().getProjectionBounds());
1924            } catch (IllegalArgumentException | IllegalStateException e) {
1925                throw BugReport.intercept(e)
1926                               .put("graphics", graphics).put("tileSource", tileSource).put("currentZoomLevel", currentZoomLevel);
1927            }
1928        }
1929
1930        private void allocateCacheMemory() {
1931            if (memory == null) {
1932                MemoryManager manager = MemoryManager.getInstance();
1933                if (manager.isAvailable(getEstimatedCacheSize())) {
1934                    try {
1935                        memory = manager.allocateMemory("tile source layer", getEstimatedCacheSize(), Object::new);
1936                    } catch (NotEnoughMemoryException e) {
1937                        Logging.warn("Could not allocate tile source memory", e);
1938                    }
1939                }
1940            }
1941        }
1942
1943        protected long getEstimatedCacheSize() {
1944            return 4L * tileSource.getTileSize() * tileSource.getTileSize() * estimateTileCacheSize();
1945        }
1946
1947        @Override
1948        public void detachFromMapView(MapViewEvent event) {
1949            event.getMapView().removeMouseListener(adapter);
1950            MapView.removeZoomChangeListener(AbstractTileSourceLayer.this);
1951            super.detachFromMapView(event);
1952            if (memory != null) {
1953                memory.free();
1954            }
1955        }
1956    }
1957
1958    @Override
1959    public void projectionChanged(Projection oldValue, Projection newValue) {
1960        super.projectionChanged(oldValue, newValue);
1961        displaySettings.setOffsetBookmark(displaySettings.getOffsetBookmark());
1962        if (tileCache != null) {
1963            tileCache.clear();
1964        }
1965    }
1966
1967    @Override
1968    protected List<OffsetMenuEntry> getOffsetMenuEntries() {
1969        return OffsetBookmark.getBookmarks()
1970            .stream()
1971            .filter(b -> b.isUsable(this))
1972            .map(OffsetMenuBookmarkEntry::new)
1973            .collect(Collectors.toList());
1974    }
1975
1976    /**
1977     * An entry for a bookmark in the offset menu.
1978     * @author Michael Zangl
1979     */
1980    private class OffsetMenuBookmarkEntry implements OffsetMenuEntry {
1981        private final OffsetBookmark bookmark;
1982
1983        OffsetMenuBookmarkEntry(OffsetBookmark bookmark) {
1984            this.bookmark = bookmark;
1985
1986        }
1987
1988        @Override
1989        public String getLabel() {
1990            return bookmark.getName();
1991        }
1992
1993        @Override
1994        public boolean isActive() {
1995            EastNorth offset = bookmark.getDisplacement(ProjectionRegistry.getProjection());
1996            EastNorth active = getDisplaySettings().getDisplacement();
1997            return Utils.equalsEpsilon(offset.east(), active.east()) && Utils.equalsEpsilon(offset.north(), active.north());
1998        }
1999
2000        @Override
2001        public void actionPerformed() {
2002            getDisplaySettings().setOffsetBookmark(bookmark);
2003        }
2004    }
2005}