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