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