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