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