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