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