diff --git a/src/org/openstreetmap/josm/data/Bounds.java b/src/org/openstreetmap/josm/data/Bounds.java
index 5cdbd0e..8108239 100644
--- a/src/org/openstreetmap/josm/data/Bounds.java
+++ b/src/org/openstreetmap/josm/data/Bounds.java
@@ -396,6 +396,28 @@ public class Bounds {
     }
 
     /**
+     * Compute the intersection of this with an other bounds object.
+     * @param other The other bounds
+     * @return The intersection area or <code>null</code> if they do not intersect.
+     */
+    public Bounds intersect(Bounds other) {
+        if (crosses180thMeridian() || other.crosses180thMeridian()) {
+            throw new UnsupportedOperationException();
+        } else {
+            Bounds bounds = new Bounds(
+                    Math.max(minLat, other.minLat),
+                    Math.max(minLon, other.minLon),
+                    Math.min(maxLat, other.maxLat),
+                    Math.min(maxLon, other.maxLon), false);
+            if  (bounds.minLat >= bounds.maxLat || bounds.minLon >= bounds.maxLon) {
+                return null;
+            } else {
+                return bounds;
+            }
+        }
+    }
+
+    /**
      * Converts the lat/lon bounding box to an object of type Rectangle2D.Double
      * @return the bounding box to Rectangle2D.Double
      */
diff --git a/src/org/openstreetmap/josm/gui/MapViewState.java b/src/org/openstreetmap/josm/gui/MapViewState.java
index 6466eef..ebe4f44 100644
--- a/src/org/openstreetmap/josm/gui/MapViewState.java
+++ b/src/org/openstreetmap/josm/gui/MapViewState.java
@@ -4,6 +4,7 @@ package org.openstreetmap.josm.gui;
 import java.awt.Container;
 import java.awt.Point;
 import java.awt.Rectangle;
+import java.awt.Shape;
 import java.awt.geom.AffineTransform;
 import java.awt.geom.Area;
 import java.awt.geom.Path2D;
@@ -20,6 +21,7 @@ import org.openstreetmap.josm.data.coor.EastNorth;
 import org.openstreetmap.josm.data.coor.LatLon;
 import org.openstreetmap.josm.data.projection.Projecting;
 import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.data.projection.ShiftedProjecting;
 import org.openstreetmap.josm.gui.download.DownloadDialog;
 import org.openstreetmap.josm.tools.bugreport.BugReport;
 
@@ -138,6 +140,15 @@ public final class MapViewState {
     }
 
     /**
+     * Gets the MapViewPoint representation for a position in view coordinates.
+     * @param point The point in view space.
+     * @return The MapViewPoint.
+     */
+    public MapViewPoint getForView(Point2D point) {
+        return new MapViewViewPoint(point.getX(), point.getY());
+    }
+
+    /**
      * Gets the {@link MapViewPoint} for the given {@link EastNorth} coordinate.
      * @param eastNorth the position.
      * @return The point for that position.
@@ -217,6 +228,11 @@ public final class MapViewState {
     }
 
     public Area getArea(Bounds bounds) {
+        Path2D area = getPath(bounds);
+        return new Area(area);
+    }
+
+    private Path2D getPath(Bounds bounds) {
         Path2D area = new Path2D.Double();
         bounds.visitEdge(getProjection(), latlon -> {
             MapViewPoint point = getPointFor(latlon);
@@ -227,7 +243,7 @@ public final class MapViewState {
             }
         });
         area.closePath();
-        return new Area(area);
+        return area;
     }
 
     /**
@@ -293,6 +309,15 @@ public final class MapViewState {
     }
 
     /**
+     * Creates a shifted mapviewstate.
+     * @param offset The delta to apply in east/north space
+     * @return The shifted MapViewState
+     */
+    public MapViewState shifted(EastNorth offset) {
+        return new MapViewState(new ShiftedProjecting(projecting, offset), this);
+    }
+
+    /**
      * Create the default {@link MapViewState} object for the given map view. The screen position won't be set so that this method can be used
      * before the view was added to the hirarchy.
      * @param width The view width
@@ -390,6 +415,15 @@ public final class MapViewState {
         }
 
         /**
+         * Gets a rectangle from this point to an other point in lat/lon space, clamped to the world bounds.
+         * @param p2 The other point
+         * @return The rectangle
+         */
+        public MapViewLatLonRectangle latLonRectTo(MapViewPoint p2) {
+            return new MapViewLatLonRectangle(getLatLonClamped(), p2.getLatLonClamped());
+        }
+
+        /**
          * Add the given offset to this point
          * @param en The offset in east/north space.
          * @return The new point
@@ -455,10 +489,45 @@ public final class MapViewState {
     }
 
     /**
+     * This is a shape on the map view area.
+     * @author Michael Zangl
+     * @since xxx
+     */
+    public interface MapViewArea {
+
+        /**
+         * Gets the shape in view space.
+         * @return The area in view coordinates
+         */
+        public Shape getInView();
+
+        /**
+         * Gets the real bounds that enclose this rectangle.
+         * This is computed respecting that the borders of this rectangle may not be a straignt line in latlon coordinates.
+         * @return The bounds.
+         * @since 10458
+         */
+        public Bounds getLatLonBoundsBox();
+
+        /**
+         * Gets the projection bounds for this rectangle.
+         * @return The projection bounds.
+         */
+        public ProjectionBounds getProjectionBounds();
+
+        /**
+         * Check if the given point is contained in this rectangle.
+         * @param point The position
+         * @return true if the point is contained in this shape.
+         */
+        public boolean contains(MapViewPoint point);
+    }
+
+    /**
      * A rectangle on the MapView. It is rectangular in screen / EastNorth space.
      * @author Michael Zangl
      */
-    public class MapViewRectangle {
+    public class MapViewRectangle implements MapViewArea {
         private final MapViewPoint p1;
         private final MapViewPoint p2;
 
@@ -472,10 +541,7 @@ public final class MapViewState {
             this.p2 = p2;
         }
 
-        /**
-         * Gets the projection bounds for this rectangle.
-         * @return The projection bounds.
-         */
+        @Override
         public ProjectionBounds getProjectionBounds() {
             ProjectionBounds b = new ProjectionBounds(p1.getEastNorth());
             b.extend(p2.getEastNorth());
@@ -493,12 +559,7 @@ public final class MapViewState {
             return b;
         }
 
-        /**
-         * Gets the real bounds that enclose this rectangle.
-         * This is computed respecting that the borders of this rectangle may not be a straignt line in latlon coordinates.
-         * @return The bounds.
-         * @since 10458
-         */
+        @Override
         public Bounds getLatLonBoundsBox() {
             // TODO @michael2402: Use hillclimb.
             return projecting.getBaseProjection().getLatLonBoundsBox(getProjectionBounds());
@@ -509,6 +570,7 @@ public final class MapViewState {
          * @return The rectangle.
          * @since 10651
          */
+        @Override
         public Rectangle2D getInView() {
             double x1 = p1.getInViewX();
             double y1 = p1.getInViewY();
@@ -516,6 +578,46 @@ public final class MapViewState {
             double y2 = p2.getInViewY();
             return new Rectangle2D.Double(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x1 - x2), Math.abs(y1 - y2));
         }
+
+        @Override
+        public boolean contains(MapViewPoint point) {
+            return getInView().contains(point.getInView());
+        }
     }
 
+    /**
+     * A rectangle in lat/lon space
+     * @author Michael Zangl
+     * @since xxx
+     */
+    public class MapViewLatLonRectangle implements MapViewArea {
+
+        private final Bounds bounds;
+
+        MapViewLatLonRectangle(LatLon l1, LatLon l2) {
+            bounds = new Bounds(l1);
+            bounds.extend(l2);
+        }
+
+        @Override
+        public Shape getInView() {
+            return getPath(bounds);
+        }
+
+        @Override
+        public boolean contains(MapViewPoint point) {
+            return bounds.contains(point.getLatLon());
+        }
+
+        @Override
+        public Bounds getLatLonBoundsBox() {
+            return new Bounds(bounds);
+        }
+
+        @Override
+        public ProjectionBounds getProjectionBounds() {
+            return new ProjectionBounds(getProjection().latlon2eastNorth(bounds.getMin()),
+                    getProjection().latlon2eastNorth(bounds.getMax()));
+        }
+    }
 }
diff --git a/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java b/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
index 13e0392..a82dd1e 100644
--- a/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
@@ -3,69 +3,36 @@ package org.openstreetmap.josm.gui.layer;
 
 import static org.openstreetmap.josm.tools.I18n.tr;
 
-import java.awt.Color;
 import java.awt.Component;
-import java.awt.Dimension;
-import java.awt.Font;
-import java.awt.Graphics;
 import java.awt.Graphics2D;
-import java.awt.GridBagLayout;
 import java.awt.Image;
-import java.awt.Point;
-import java.awt.Toolkit;
 import java.awt.event.ActionEvent;
-import java.awt.event.MouseAdapter;
-import java.awt.event.MouseEvent;
-import java.awt.geom.Point2D;
-import java.awt.geom.Rectangle2D;
-import java.awt.image.BufferedImage;
 import java.awt.image.ImageObserver;
 import java.io.File;
-import java.io.IOException;
 import java.net.MalformedURLException;
 import java.net.URL;
-import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.Date;
-import java.util.LinkedList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ConcurrentSkipListSet;
 import java.util.concurrent.atomic.AtomicInteger;
-import java.util.function.Consumer;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-import java.util.stream.IntStream;
-import java.util.stream.Stream;
 
 import javax.swing.AbstractAction;
 import javax.swing.Action;
-import javax.swing.BorderFactory;
 import javax.swing.JCheckBoxMenuItem;
 import javax.swing.JLabel;
 import javax.swing.JMenuItem;
-import javax.swing.JOptionPane;
 import javax.swing.JPanel;
 import javax.swing.JPopupMenu;
-import javax.swing.JSeparator;
-import javax.swing.JTextField;
 import javax.swing.Timer;
 
-import org.openstreetmap.gui.jmapviewer.AttributionSupport;
-import org.openstreetmap.gui.jmapviewer.MemoryTileCache;
 import org.openstreetmap.gui.jmapviewer.OsmTileLoader;
 import org.openstreetmap.gui.jmapviewer.Tile;
 import org.openstreetmap.gui.jmapviewer.TileXY;
-import org.openstreetmap.gui.jmapviewer.interfaces.CachedTileLoader;
-import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
 import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource;
-import org.openstreetmap.gui.jmapviewer.interfaces.TileCache;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileLoaderListener;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
@@ -75,7 +42,6 @@ import org.openstreetmap.josm.actions.ImageryAdjustAction;
 import org.openstreetmap.josm.actions.RenameLayerAction;
 import org.openstreetmap.josm.actions.SaveActionBase;
 import org.openstreetmap.josm.data.Bounds;
-import org.openstreetmap.josm.data.ProjectionBounds;
 import org.openstreetmap.josm.data.coor.EastNorth;
 import org.openstreetmap.josm.data.coor.LatLon;
 import org.openstreetmap.josm.data.imagery.ImageryInfo;
@@ -83,26 +49,20 @@ import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
 import org.openstreetmap.josm.data.imagery.TileLoaderFactory;
 import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
 import org.openstreetmap.josm.data.preferences.IntegerProperty;
-import org.openstreetmap.josm.gui.ExtendedDialog;
 import org.openstreetmap.josm.gui.MapFrame;
 import org.openstreetmap.josm.gui.MapView;
-import org.openstreetmap.josm.gui.MapViewState.MapViewRectangle;
 import org.openstreetmap.josm.gui.NavigatableComponent.ZoomChangeListener;
-import org.openstreetmap.josm.gui.PleaseWaitRunnable;
 import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
 import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
 import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings.FilterChangeListener;
-import org.openstreetmap.josm.gui.layer.imagery.TileCoordinateConverter;
 import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings;
 import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings.DisplaySettingsChangeEvent;
 import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings.DisplaySettingsChangeListener;
+import org.openstreetmap.josm.gui.layer.imagery.TileSourcePainter;
 import org.openstreetmap.josm.gui.progress.ProgressMonitor;
 import org.openstreetmap.josm.gui.util.GuiHelper;
 import org.openstreetmap.josm.io.WMSLayerImporter;
 import org.openstreetmap.josm.tools.GBC;
-import org.openstreetmap.josm.tools.MemoryManager;
-import org.openstreetmap.josm.tools.MemoryManager.MemoryHandle;
-import org.openstreetmap.josm.tools.MemoryManager.NotEnoughMemoryException;
 
 /**
  * Base abstract class that supports displaying images provided by TileSource. It might be TMS source, WMS or WMTS
@@ -115,9 +75,10 @@ import org.openstreetmap.josm.tools.MemoryManager.NotEnoughMemoryException;
  * @since 3715
  * @since 8526 (copied from TMSLayer)
  */
-public abstract class AbstractTileSourceLayer<T extends AbstractTMSTileSource> extends ImageryLayer
-implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeListener, DisplaySettingsChangeListener {
+public abstract class AbstractTileSourceLayer<T extends AbstractTMSTileSource> extends ImageryLayer implements
+        ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeListener, DisplaySettingsChangeListener {
     private static final String PREFERENCE_PREFIX = "imagery.generic";
+
     /**
      * Registers all setting properties
      */
@@ -129,22 +90,14 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
     public static final int MAX_ZOOM = 30;
     /** minium zoom level supported */
     public static final int MIN_ZOOM = 2;
-    private static final Font InfoFont = new Font("sansserif", Font.BOLD, 13);
 
     /** minimum zoom level to show to user */
     public static final IntegerProperty PROP_MIN_ZOOM_LVL = new IntegerProperty(PREFERENCE_PREFIX + ".min_zoom_lvl", 2);
     /** maximum zoom level to show to user */
-    public static final IntegerProperty PROP_MAX_ZOOM_LVL = new IntegerProperty(PREFERENCE_PREFIX + ".max_zoom_lvl", 20);
+    public static final IntegerProperty PROP_MAX_ZOOM_LVL = new IntegerProperty(PREFERENCE_PREFIX + ".max_zoom_lvl",
+            20);
 
     //public static final BooleanProperty PROP_DRAW_DEBUG = new BooleanProperty(PREFERENCE_PREFIX + ".draw_debug", false);
-    /**
-     * Zoomlevel at which tiles is currently downloaded.
-     * Initial zoom lvl is set to bestZoom
-     */
-    public int currentZoomLevel;
-
-    private final AttributionSupport attribution = new AttributionSupport();
-    private final TileHolder clickedTileHolder = new TileHolder();
 
     /**
      * Offset between calculated zoom level and zoom level used to download and show tiles. Negative values will result in
@@ -152,40 +105,16 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
      */
     public static final IntegerProperty ZOOM_OFFSET = new IntegerProperty(PREFERENCE_PREFIX + ".zoom_offset", 0);
 
-    /*
-     *  use MemoryTileCache instead of tileLoader JCS cache, as tileLoader caches only content (byte[] of image)
-     *  and MemoryTileCache caches whole Tile. This gives huge performance improvement when a lot of tiles are visible
-     *  in MapView (for example - when limiting min zoom in imagery)
-     *
-     *  Use per-layer tileCache instance, as the more layers there are, the more tiles needs to be cached
-     */
-    protected TileCache tileCache; // initialized together with tileSource
-    protected T tileSource;
-    protected TileLoader tileLoader;
-
     /**
      * A timer that is used to delay invalidation events if required.
      */
     private final Timer invalidateLaterTimer = new Timer(100, e -> this.invalidate());
 
-    private final MouseAdapter adapter = new MouseAdapter() {
-        @Override
-        public void mouseClicked(MouseEvent e) {
-            if (!isVisible()) return;
-            if (e.getButton() == MouseEvent.BUTTON3) {
-                clickedTileHolder.setTile(getTileForPixelpos(e.getX(), e.getY()));
-                new TileSourceLayerPopup().show(e.getComponent(), e.getX(), e.getY());
-            } else if (e.getButton() == MouseEvent.BUTTON1) {
-                attribution.handleAttribution(e.getPoint(), true);
-            }
-        }
-    };
-
     private final TileSourceDisplaySettings displaySettings = createDisplaySettings();
 
     private final ImageryAdjustAction adjustAction = new ImageryAdjustAction(this);
-    // prepared to be moved to the painter
-    private TileCoordinateConverter coordinateConverter;
+
+    private HashMap<MapView, TileSourcePainter<T>> painters = new HashMap<>();
 
     /**
      * Creates Tile Source based Imagery Layer based on Imagery Info
@@ -222,36 +151,22 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
         invalidate();
     }
 
-    protected abstract TileLoaderFactory getTileLoaderFactory();
-
     /**
-     *
-     * @param info imagery info
-     * @return TileSource for specified ImageryInfo
-     * @throws IllegalArgumentException when Imagery is not supported by layer
+     * Generate the tile loader
+     * @param tileSource A tile source that is already generated.
+     * @return The tile loader.
      */
-    protected abstract T getTileSource(ImageryInfo info);
-
-    protected Map<String, String> getHeaders(T tileSource) {
-        if (tileSource instanceof TemplatedTileSource) {
-            return ((TemplatedTileSource) tileSource).getHeaders();
-        }
-        return null;
-    }
-
-    protected void initTileSource(T tileSource) {
-        coordinateConverter = new TileCoordinateConverter(Main.map.mapView, tileSource, getDisplaySettings());
-        attribution.initialize(tileSource);
-
-        currentZoomLevel = getBestZoom();
-
+    public TileLoader generateTileLoader(T tileSource) {
         Map<String, String> headers = getHeaders(tileSource);
 
-        tileLoader = getTileLoaderFactory().makeTileLoader(this, headers);
+        TileLoader loader = getTileLoaderFactory().makeTileLoader(this, headers);
+        if (loader != null) {
+            return loader;
+        }
 
         try {
             if ("file".equalsIgnoreCase(new URL(tileSource.getBaseUrl()).getProtocol())) {
-                tileLoader = new OsmTileLoader(this);
+                return new OsmTileLoader(this);
             }
         } catch (MalformedURLException e) {
             // ignore, assume that this is not a file
@@ -260,10 +175,34 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
             }
         }
 
-        if (tileLoader == null)
-            tileLoader = new OsmTileLoader(this, headers);
+        return new OsmTileLoader(this, headers);
+    }
+
+    /**
+     * Generates the tile source for this layer.
+     * @return The tile source
+     */
+    public T getTileSource() {
+        return getTileSource(getInfo());
+    }
+
+    protected abstract TileLoaderFactory getTileLoaderFactory();
+
+    /**
+     * Used by the default {@link TileSourcePainter} to create the tile source.
+     * @param info imagery info
+     * @return TileSource for specified ImageryInfo
+     * @throws IllegalArgumentException when Imagery is not supported by layer
+     */
+    protected T getTileSource(ImageryInfo info) {
+        throw new UnsupportedOperationException();
+    }
 
-        tileCache = new MemoryTileCache(estimateTileCacheSize());
+    protected Map<String, String> getHeaders(T tileSource) {
+        if (tileSource instanceof TemplatedTileSource) {
+            return ((TemplatedTileSource) tileSource).getHeaders();
+        }
+        return null;
     }
 
     @Override
@@ -280,22 +219,6 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
     }
 
     /**
-     * Clears the tile cache.
-     *
-     * If the current tileLoader is an instance of OsmTileLoader, a new
-     * TmsTileClearController is created and passed to the according clearCache
-     * method.
-     *
-     * @param monitor not used in this implementation - as cache clear is instaneus
-     */
-    public void clearTileCache(ProgressMonitor monitor) {
-        if (tileLoader instanceof CachedTileLoader) {
-            ((CachedTileLoader) tileLoader).clearCache(tileSource);
-        }
-        tileCache.clear();
-    }
-
-    /**
      * Initiates a repaint of Main.map
      *
      * @see Main#map
@@ -361,124 +284,10 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
         return adjustAction;
     }
 
-    /**
-     * Returns average number of screen pixels per tile pixel for current mapview
-     * @param zoom zoom level
-     * @return average number of screen pixels per tile pixel
-     */
-    private double getScaleFactor(int zoom) {
-        if (coordinateConverter != null) {
-            return coordinateConverter.getScaleFactor(zoom);
-        } else {
-            return 1;
-        }
-    }
-
-    protected int getBestZoom() {
-        double factor = getScaleFactor(1); // check the ratio between area of tilesize at zoom 1 to current view
-        double result = Math.log(factor)/Math.log(2)/2;
-        /*
-         * Math.log(factor)/Math.log(2) - gives log base 2 of factor
-         * We divide result by 2, as factor contains ratio between areas. We could do Math.sqrt before log, or just divide log by 2
-         *
-         * ZOOM_OFFSET controls, whether we work with overzoomed or underzoomed tiles. Positive ZOOM_OFFSET
-         * is for working with underzoomed tiles (higher quality when working with aerial imagery), negative ZOOM_OFFSET
-         * is for working with overzoomed tiles (big, pixelated), which is good when working with high-dpi screens and/or
-         * maps as a imagery layer
-         */
-
-        int intResult = (int) Math.round(result + 1 + ZOOM_OFFSET.get() / 1.9);
-
-        intResult = Math.min(intResult, getMaxZoomLvl());
-        intResult = Math.max(intResult, getMinZoomLvl());
-        return intResult;
-    }
-
     private static boolean actionSupportLayers(List<Layer> layers) {
         return layers.size() == 1 && layers.get(0) instanceof TMSLayer;
     }
 
-    private final class ShowTileInfoAction extends AbstractAction {
-
-        private ShowTileInfoAction() {
-            super(tr("Show tile info"));
-        }
-
-        private String getSizeString(int size) {
-            StringBuilder ret = new StringBuilder();
-            return ret.append(size).append('x').append(size).toString();
-        }
-
-        private JTextField createTextField(String text) {
-            JTextField ret = new JTextField(text);
-            ret.setEditable(false);
-            ret.setBorder(BorderFactory.createEmptyBorder());
-            return ret;
-        }
-
-        @Override
-        public void actionPerformed(ActionEvent ae) {
-            Tile clickedTile = clickedTileHolder.getTile();
-            if (clickedTile != null) {
-                ExtendedDialog ed = new ExtendedDialog(Main.parent, tr("Tile Info"), new String[]{tr("OK")});
-                JPanel panel = new JPanel(new GridBagLayout());
-                Rectangle2D displaySize = coordinateConverter.getRectangleForTile(clickedTile);
-                String url = "";
-                try {
-                    url = clickedTile.getUrl();
-                } catch (IOException e) {
-                    // silence exceptions
-                    Main.trace(e);
-                }
-
-                String[][] content = {
-                        {"Tile name", clickedTile.getKey()},
-                        {"Tile url", url},
-                        {"Tile size", getSizeString(clickedTile.getTileSource().getTileSize()) },
-                        {"Tile display size", new StringBuilder().append(displaySize.getWidth())
-                                                                 .append('x')
-                                                                 .append(displaySize.getHeight()).toString()},
-                };
-
-                for (String[] entry: content) {
-                    panel.add(new JLabel(tr(entry[0]) + ':'), GBC.std());
-                    panel.add(GBC.glue(5, 0), GBC.std());
-                    panel.add(createTextField(entry[1]), GBC.eol().fill(GBC.HORIZONTAL));
-                }
-
-                for (Entry<String, String> e: clickedTile.getMetadata().entrySet()) {
-                    panel.add(new JLabel(tr("Metadata ") + tr(e.getKey()) + ':'), GBC.std());
-                    panel.add(GBC.glue(5, 0), GBC.std());
-                    String value = e.getValue();
-                    if ("lastModification".equals(e.getKey()) || "expirationTime".equals(e.getKey())) {
-                        value = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(Long.parseLong(value)));
-                    }
-                    panel.add(createTextField(value), GBC.eol().fill(GBC.HORIZONTAL));
-
-                }
-                ed.setIcon(JOptionPane.INFORMATION_MESSAGE);
-                ed.setContent(panel);
-                ed.showDialog();
-            }
-        }
-    }
-
-    private final class LoadTileAction extends AbstractAction {
-
-        private LoadTileAction() {
-            super(tr("Load tile"));
-        }
-
-        @Override
-        public void actionPerformed(ActionEvent ae) {
-            Tile clickedTile = clickedTileHolder.getTile();
-            if (clickedTile != null) {
-                loadTile(clickedTile, true);
-                invalidate();
-            }
-        }
-    }
-
     private class AutoZoomAction extends AbstractAction implements LayerAction {
         AutoZoomAction() {
             super(tr("Auto zoom"));
@@ -548,165 +357,27 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
         }
     }
 
-    private class LoadAllTilesAction extends AbstractAction {
-        LoadAllTilesAction() {
-            super(tr("Load all tiles"));
-        }
-
-        @Override
-        public void actionPerformed(ActionEvent ae) {
-            loadAllTiles(true);
-        }
-    }
-
-    private class LoadErroneusTilesAction extends AbstractAction {
-        LoadErroneusTilesAction() {
-            super(tr("Load all error tiles"));
-        }
-
-        @Override
-        public void actionPerformed(ActionEvent ae) {
-            loadAllErrorTiles(true);
-        }
-    }
-
-    private class ZoomToNativeLevelAction extends AbstractAction {
-        ZoomToNativeLevelAction() {
-            super(tr("Zoom to native resolution"));
-        }
-
-        @Override
-        public void actionPerformed(ActionEvent ae) {
-            double newFactor = Math.sqrt(getScaleFactor(currentZoomLevel));
-            Main.map.mapView.zoomToFactor(newFactor);
-            redraw();
-        }
-    }
-
-    private class ZoomToBestAction extends AbstractAction {
-        ZoomToBestAction() {
-            super(tr("Change resolution"));
-            setEnabled(!getDisplaySettings().isAutoZoom() && getBestZoom() != currentZoomLevel);
-        }
-
-        @Override
-        public void actionPerformed(ActionEvent ae) {
-            setZoomLevel(getBestZoom());
-        }
-    }
-
-    private class IncreaseZoomAction extends AbstractAction {
-        IncreaseZoomAction() {
-            super(tr("Increase zoom"));
-            setEnabled(!getDisplaySettings().isAutoZoom() && zoomIncreaseAllowed());
-        }
-
-        @Override
-        public void actionPerformed(ActionEvent ae) {
-            increaseZoomLevel();
-        }
-    }
-
-    private class DecreaseZoomAction extends AbstractAction {
-        DecreaseZoomAction() {
-            super(tr("Decrease zoom"));
-            setEnabled(!getDisplaySettings().isAutoZoom() && zoomDecreaseAllowed());
-        }
-
-        @Override
-        public void actionPerformed(ActionEvent ae) {
-            decreaseZoomLevel();
-        }
-    }
-
-    private class FlushTileCacheAction extends AbstractAction {
-        FlushTileCacheAction() {
-            super(tr("Flush tile cache"));
-            setEnabled(tileLoader instanceof CachedTileLoader);
-        }
-
-        @Override
-        public void actionPerformed(ActionEvent ae) {
-            new PleaseWaitRunnable(tr("Flush tile cache")) {
-                @Override
-                protected void realRun() {
-                    clearTileCache(getProgressMonitor());
-                }
-
-                @Override
-                protected void finish() {
-                    // empty - flush is instaneus
-                }
-
-                @Override
-                protected void cancel() {
-                    // empty - flush is instaneus
-                }
-            }.run();
-        }
-    }
-
-    /**
-     * Simple class to keep clickedTile within hookUpMapView
-     */
-    private static final class TileHolder {
-        private Tile t;
-
-        public Tile getTile() {
-            return t;
-        }
-
-        public void setTile(Tile t) {
-            this.t = t;
-        }
-    }
-
-    /**
-     * Creates popup menu items and binds to mouse actions
-     */
     @Override
     public void hookUpMapView() {
-        // this needs to be here and not in constructor to allow empty TileSource class construction
-        // using SessionWriter
-        initializeIfRequired();
-
-        super.hookUpMapView();
     }
 
     @Override
     public LayerPainter attachToMapView(MapViewEvent event) {
-        initializeIfRequired();
-
-        event.getMapView().addMouseListener(adapter);
+        GuiHelper.assertCallFromEdt();
         MapView.addZoomChangeListener(this);
 
         if (this instanceof NativeScaleLayer) {
             event.getMapView().setNativeScaleLayer((NativeScaleLayer) this);
         }
 
-        // 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.
-        // FIXME: Check if this is still required.
-        event.getMapView().repaint(500);
-
-        return super.attachToMapView(event);
-    }
-
-    private void initializeIfRequired() {
-        if (tileSource == null) {
-            tileSource = getTileSource(info);
-            if (tileSource == null) {
-                throw new IllegalArgumentException(tr("Failed to create tile source"));
-            }
-            // check if projection is supported
-            projectionChanged(null, Main.getProjection());
-            initTileSource(this.tileSource);
-        }
+        TileSourcePainter<T> painter = createMapViewPainter(event);
+        painters.put(event.getMapView(), painter);
+        return painter;
     }
 
     @Override
-    protected LayerPainter createMapViewPainter(MapViewEvent event) {
-        return new TileSourcePainter();
+    protected TileSourcePainter<T> createMapViewPainter(MapViewEvent event) {
+        return new TileSourcePainter<>(this, event.getMapView());
     }
 
     /**
@@ -715,51 +386,29 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
     public class TileSourceLayerPopup extends JPopupMenu {
         /**
          * Constructs a new {@code TileSourceLayerPopup}.
+         * @param mv
          */
-        public TileSourceLayerPopup() {
+        public TileSourceLayerPopup(MapView mv) {
             for (Action a : getCommonEntries()) {
-                if (a instanceof LayerAction) {
-                    add(((LayerAction) a).createMenuComponent());
-                } else {
-                    add(new JMenuItem(a));
-                }
+                addAction(a);
+            }
+            for (Action a : getMapViewEntries(mv)) {
+                addAction(a);
             }
-            add(new JSeparator());
-            add(new JMenuItem(new LoadTileAction()));
-            add(new JMenuItem(new ShowTileInfoAction()));
         }
-    }
 
-    protected int estimateTileCacheSize() {
-        Dimension screenSize = GuiHelper.getMaximumScreenSize();
-        int height = screenSize.height;
-        int width = screenSize.width;
-        int tileSize = 256; // default tile size
-        if (tileSource != null) {
-            tileSize = tileSource.getTileSize();
+        private void addAction(Action a) {
+            if (a instanceof LayerAction) {
+                add(((LayerAction) a).createMenuComponent());
+            } else {
+                add(new JMenuItem(a));
+            }
         }
-        // as we can see part of the tile at the top and at the bottom, use Math.ceil(...) + 1 to accommodate for that
-        int visibileTiles = (int) (Math.ceil((double) height / tileSize + 1) * Math.ceil((double) width / tileSize + 1));
-        // add 10% for tiles from different zoom levels
-        int ret = (int) Math.ceil(
-                Math.pow(2d, ZOOM_OFFSET.get()) * visibileTiles // use offset to decide, how many tiles are visible
-                * 4);
-        Main.info("AbstractTileSourceLayer: estimated visible tiles: {0}, estimated cache size: {1}", visibileTiles, ret);
-        return ret;
     }
 
     @Override
     public void displaySettingsChanged(DisplaySettingsChangeEvent e) {
-        if (tileSource == null) {
-            return;
-        }
         switch (e.getChangedSetting()) {
-        case TileSourceDisplaySettings.AUTO_ZOOM:
-            if (getDisplaySettings().isAutoZoom() && getBestZoom() != currentZoomLevel) {
-                setZoomLevel(getBestZoom());
-                invalidate();
-            }
-            break;
         case TileSourceDisplaySettings.AUTO_LOAD:
             if (getDisplaySettings().isAutoLoad()) {
                 invalidate();
@@ -847,190 +496,6 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
      */
     @Override
     public void zoomChanged() {
-        if (Main.isDebugEnabled()) {
-            Main.debug("zoomChanged(): " + currentZoomLevel);
-        }
-        if (tileLoader instanceof TMSCachedTileLoader) {
-            ((TMSCachedTileLoader) tileLoader).cancelOutstandingTasks();
-        }
-        invalidate();
-    }
-
-    protected int getMaxZoomLvl() {
-        if (info.getMaxZoom() != 0)
-            return checkMaxZoomLvl(info.getMaxZoom(), tileSource);
-        else
-            return getMaxZoomLvl(tileSource);
-    }
-
-    protected int getMinZoomLvl() {
-        if (info.getMinZoom() != 0)
-            return checkMinZoomLvl(info.getMinZoom(), tileSource);
-        else
-            return getMinZoomLvl(tileSource);
-    }
-
-    /**
-     *
-     * @return if its allowed to zoom in
-     */
-    public boolean zoomIncreaseAllowed() {
-        boolean zia = currentZoomLevel < this.getMaxZoomLvl();
-        if (Main.isDebugEnabled()) {
-            Main.debug("zoomIncreaseAllowed(): " + zia + ' ' + currentZoomLevel + " vs. " + this.getMaxZoomLvl());
-        }
-        return zia;
-    }
-
-    /**
-     * Zoom in, go closer to map.
-     *
-     * @return    true, if zoom increasing was successful, false otherwise
-     */
-    public boolean increaseZoomLevel() {
-        if (zoomIncreaseAllowed()) {
-            currentZoomLevel++;
-            if (Main.isDebugEnabled()) {
-                Main.debug("increasing zoom level to: " + currentZoomLevel);
-            }
-            zoomChanged();
-        } else {
-            Main.warn("Current zoom level ("+currentZoomLevel+") could not be increased. "+
-                    "Max.zZoom Level "+this.getMaxZoomLvl()+" reached.");
-            return false;
-        }
-        return true;
-    }
-
-    /**
-     * Sets the zoom level of the layer
-     * @param zoom zoom level
-     * @return true, when zoom has changed to desired value, false if it was outside supported zoom levels
-     */
-    public boolean setZoomLevel(int zoom) {
-        if (zoom == currentZoomLevel) return true;
-        if (zoom > this.getMaxZoomLvl()) return false;
-        if (zoom < this.getMinZoomLvl()) return false;
-        currentZoomLevel = zoom;
-        zoomChanged();
-        return true;
-    }
-
-    /**
-     * Check if zooming out is allowed
-     *
-     * @return    true, if zooming out is allowed (currentZoomLevel &gt; minZoomLevel)
-     */
-    public boolean zoomDecreaseAllowed() {
-        boolean zda = currentZoomLevel > this.getMinZoomLvl();
-        if (Main.isDebugEnabled()) {
-            Main.debug("zoomDecreaseAllowed(): " + zda + ' ' + currentZoomLevel + " vs. " + this.getMinZoomLvl());
-        }
-        return zda;
-    }
-
-    /**
-     * Zoom out from map.
-     *
-     * @return    true, if zoom increasing was successfull, false othervise
-     */
-    public boolean decreaseZoomLevel() {
-        if (zoomDecreaseAllowed()) {
-            if (Main.isDebugEnabled()) {
-                Main.debug("decreasing zoom level to: " + currentZoomLevel);
-            }
-            currentZoomLevel--;
-            zoomChanged();
-        } else {
-            return false;
-        }
-        return true;
-    }
-
-    /*
-     * We use these for quick, hackish calculations.  They
-     * are temporary only and intentionally not inserted
-     * into the tileCache.
-     */
-    private Tile tempCornerTile(Tile t) {
-        int x = t.getXtile() + 1;
-        int y = t.getYtile() + 1;
-        int zoom = t.getZoom();
-        Tile tile = getTile(x, y, zoom);
-        if (tile != null)
-            return tile;
-        return new Tile(tileSource, x, y, zoom);
-    }
-
-    private Tile getOrCreateTile(TilePosition tilePosition) {
-        return getOrCreateTile(tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
-    }
-
-    private Tile getOrCreateTile(int x, int y, int zoom) {
-        Tile tile = getTile(x, y, zoom);
-        if (tile == null) {
-            tile = new Tile(tileSource, x, y, zoom);
-            tileCache.addTile(tile);
-        }
-
-        if (!tile.isLoaded()) {
-            tile.loadPlaceholderFromCache(tileCache);
-        }
-        return tile;
-    }
-
-    private Tile getTile(TilePosition tilePosition) {
-        return getTile(tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
-    }
-
-    /**
-     * Returns tile at given position.
-     * This can and will return null for tiles that are not already in the cache.
-     * @param x tile number on the x axis of the tile to be retrieved
-     * @param y tile number on the y axis of the tile to be retrieved
-     * @param zoom zoom level of the tile to be retrieved
-     * @return tile at given position
-     */
-    private Tile getTile(int x, int y, int zoom) {
-        if (x < tileSource.getTileXMin(zoom) || x > tileSource.getTileXMax(zoom)
-         || y < tileSource.getTileYMin(zoom) || y > tileSource.getTileYMax(zoom))
-            return null;
-        return tileCache.getTile(tileSource, x, y, zoom);
-    }
-
-    private boolean loadTile(Tile tile, boolean force) {
-        if (tile == null)
-            return false;
-        if (!force && (tile.isLoaded() || tile.hasError()))
-            return false;
-        if (tile.isLoading())
-            return false;
-        tileLoader.createTileLoaderJob(tile).submit(force);
-        return true;
-    }
-
-    private TileSet getVisibleTileSet() {
-        MapView mv = Main.map.mapView;
-        MapViewRectangle area = mv.getState().getViewArea();
-        ProjectionBounds bounds = area.getProjectionBounds();
-        return getTileSet(bounds.getMin(), bounds.getMax(), currentZoomLevel);
-    }
-
-    protected void loadAllTiles(boolean force) {
-        TileSet ts = getVisibleTileSet();
-
-        // if there is more than 18 tiles on screen in any direction, do not load all tiles!
-        if (ts.tooLarge()) {
-            Main.warn("Not downloading all tiles because there is more than 18 tiles on an axis!");
-            return;
-        }
-        ts.loadAllTiles(force);
-        invalidate();
-    }
-
-    protected void loadAllErrorTiles(boolean force) {
-        TileSet ts = getVisibleTileSet();
-        ts.loadAllErrorTiles(force);
         invalidate();
     }
 
@@ -1061,737 +526,6 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
         });
     }
 
-    private boolean imageLoaded(Image i) {
-        if (i == null)
-            return false;
-        int status = Toolkit.getDefaultToolkit().checkImage(i, -1, -1, this);
-        if ((status & ALLBITS) != 0)
-            return true;
-        return false;
-    }
-
-    /**
-     * Returns the image for the given tile image is loaded.
-     * Otherwise returns  null.
-     *
-     * @param tile the Tile for which the image should be returned
-     * @return  the image of the tile or null.
-     */
-    private Image getLoadedTileImage(Tile tile) {
-        Image img = tile.getImage();
-        if (!imageLoaded(img))
-            return null;
-        return img;
-    }
-
-    // 'source' is the pixel coordinates for the area that
-    // the img is capable of filling in.  However, we probably
-    // only want a portion of it.
-    //
-    // 'border' is the screen cordinates that need to be drawn.
-    //  We must not draw outside of it.
-    private void drawImageInside(Graphics g, Image sourceImg, Rectangle2D source, Rectangle2D border) {
-        Rectangle2D target = source;
-
-        // If a border is specified, only draw the intersection
-        // if what we have combined with what we are supposed to draw.
-        if (border != null) {
-            target = source.createIntersection(border);
-            if (Main.isDebugEnabled()) {
-                Main.debug("source: " + source + "\nborder: " + border + "\nintersection: " + target);
-            }
-        }
-
-        // All of the rectangles are in screen coordinates.  We need
-        // to how these correlate to the sourceImg pixels.  We could
-        // avoid doing this by scaling the image up to the 'source' size,
-        // but this should be cheaper.
-        //
-        // In some projections, x any y are scaled differently enough to
-        // cause a pixel or two of fudge.  Calculate them separately.
-        double imageYScaling = sourceImg.getHeight(this) / source.getHeight();
-        double imageXScaling = sourceImg.getWidth(this) / source.getWidth();
-
-        // How many pixels into the 'source' rectangle are we drawing?
-        double screenXoffset = target.getX() - source.getX();
-        double screenYoffset = target.getY() - source.getY();
-        // And how many pixels into the image itself does that correlate to?
-        int imgXoffset = (int) (screenXoffset * imageXScaling + 0.5);
-        int imgYoffset = (int) (screenYoffset * imageYScaling + 0.5);
-        // Now calculate the other corner of the image that we need
-        // by scaling the 'target' rectangle's dimensions.
-        int imgXend = imgXoffset + (int) (target.getWidth() * imageXScaling + 0.5);
-        int imgYend = imgYoffset + (int) (target.getHeight() * imageYScaling + 0.5);
-
-        if (Main.isDebugEnabled()) {
-            Main.debug("drawing image into target rect: " + target);
-        }
-        g.drawImage(sourceImg,
-                (int) target.getX(), (int) target.getY(),
-                (int) target.getMaxX(), (int) target.getMaxY(),
-                imgXoffset, imgYoffset,
-                imgXend, imgYend,
-                this);
-        if (PROP_FADE_AMOUNT.get() != 0) {
-            // dimm by painting opaque rect...
-            g.setColor(getFadeColorWithAlpha());
-            ((Graphics2D) g).fill(target);
-        }
-    }
-
-    private List<Tile> paintTileImages(Graphics g, TileSet ts) {
-        Object paintMutex = new Object();
-        List<TilePosition> missed = Collections.synchronizedList(new ArrayList<>());
-        ts.visitTiles(tile -> {
-            Image img = getLoadedTileImage(tile);
-            if (img == null) {
-                missed.add(new TilePosition(tile));
-            }
-            img = applyImageProcessors((BufferedImage) img);
-            Rectangle2D sourceRect = coordinateConverter.getRectangleForTile(tile);
-            synchronized (paintMutex) {
-                //cannot paint in parallel
-                drawImageInside(g, img, sourceRect, null);
-            }
-        }, missed::add);
-
-        return missed.stream().map(this::getOrCreateTile).collect(Collectors.toList());
-    }
-
-    // This function is called for several zoom levels, not just
-    // the current one.  It should not trigger any tiles to be
-    // downloaded.  It should also avoid polluting the tile cache
-    // with any tiles since these tiles are not mandatory.
-    //
-    // The "border" tile tells us the boundaries of where we may
-    // draw.  It will not be from the zoom level that is being
-    // drawn currently.  If drawing the displayZoomLevel,
-    // border is null and we draw the entire tile set.
-    private List<Tile> paintTileImages(Graphics g, TileSet ts, int zoom, Tile border) {
-        if (zoom <= 0) return Collections.emptyList();
-        Rectangle2D borderRect = coordinateConverter.getRectangleForTile(border);
-        List<Tile> missedTiles = new LinkedList<>();
-        // The callers of this code *require* that we return any tiles
-        // that we do not draw in missedTiles.  ts.allExistingTiles() by
-        // default will only return already-existing tiles.  However, we
-        // need to return *all* tiles to the callers, so force creation here.
-        for (Tile tile : ts.allTilesCreate()) {
-            Image img = getLoadedTileImage(tile);
-            if (img == null || tile.hasError()) {
-                if (Main.isDebugEnabled()) {
-                    Main.debug("missed tile: " + tile);
-                }
-                missedTiles.add(tile);
-                continue;
-            }
-
-            // applying all filters to this layer
-            img = applyImageProcessors((BufferedImage) img);
-
-            Rectangle2D sourceRect = coordinateConverter.getRectangleForTile(tile);
-            if (borderRect != null && !sourceRect.intersects(borderRect)) {
-                continue;
-            }
-            drawImageInside(g, img, sourceRect, borderRect);
-        }
-        return missedTiles;
-    }
-
-    private void myDrawString(Graphics g, String text, int x, int y) {
-        Color oldColor = g.getColor();
-        String textToDraw = text;
-        if (g.getFontMetrics().stringWidth(text) > tileSource.getTileSize()) {
-            // text longer than tile size, split it
-            StringBuilder line = new StringBuilder();
-            StringBuilder ret = new StringBuilder();
-            for (String s: text.split(" ")) {
-                if (g.getFontMetrics().stringWidth(line.toString() + s) > tileSource.getTileSize()) {
-                    ret.append(line).append('\n');
-                    line.setLength(0);
-                }
-                line.append(s).append(' ');
-            }
-            ret.append(line);
-            textToDraw = ret.toString();
-        }
-        int offset = 0;
-        for (String s: textToDraw.split("\n")) {
-            g.setColor(Color.black);
-            g.drawString(s, x + 1, y + offset + 1);
-            g.setColor(oldColor);
-            g.drawString(s, x, y + offset);
-            offset += g.getFontMetrics().getHeight() + 3;
-        }
-    }
-
-    private void paintTileText(TileSet ts, Tile tile, Graphics g, MapView mv, int zoom, Tile t) {
-        if (tile == null) {
-            return;
-        }
-        Point2D p = coordinateConverter.getPixelForTile(t);
-        int fontHeight = g.getFontMetrics().getHeight();
-        int x = (int) p.getX();
-        int y = (int) p.getY();
-        int texty = y + 2 + fontHeight;
-
-        /*if (PROP_DRAW_DEBUG.get()) {
-            myDrawString(g, "x=" + t.getXtile() + " y=" + t.getYtile() + " z=" + zoom + "", p.x + 2, texty);
-            texty += 1 + fontHeight;
-            if ((t.getXtile() % 32 == 0) && (t.getYtile() % 32 == 0)) {
-                myDrawString(g, "x=" + t.getXtile() / 32 + " y=" + t.getYtile() / 32 + " z=7", p.x + 2, texty);
-                texty += 1 + fontHeight;
-            }
-        }*/
-
-        /*String tileStatus = tile.getStatus();
-        if (!tile.isLoaded() && PROP_DRAW_DEBUG.get()) {
-            myDrawString(g, tr("image " + tileStatus), p.x + 2, texty);
-            texty += 1 + fontHeight;
-        }*/
-
-        if (tile.hasError() && getDisplaySettings().isShowErrors()) {
-            myDrawString(g, tr("Error") + ": " + tr(tile.getErrorMessage()), x + 2, texty);
-            //texty += 1 + fontHeight;
-        }
-
-        int xCursor = -1;
-        int yCursor = -1;
-        if (Main.isDebugEnabled()) {
-            if (yCursor < t.getYtile()) {
-                if (t.getYtile() % 32 == 31) {
-                    g.fillRect(0, y - 1, mv.getWidth(), 3);
-                } else {
-                    g.drawLine(0, y, mv.getWidth(), y);
-                }
-                //yCursor = t.getYtile();
-            }
-            // This draws the vertical lines for the entire column. Only draw them for the top tile in the column.
-            if (xCursor < t.getXtile()) {
-                if (t.getXtile() % 32 == 0) {
-                    // level 7 tile boundary
-                    g.fillRect(x - 1, 0, 3, mv.getHeight());
-                } else {
-                    g.drawLine(x, 0, x, mv.getHeight());
-                }
-                //xCursor = t.getXtile();
-            }
-        }
-    }
-
-    private LatLon getShiftedLatLon(EastNorth en) {
-        return coordinateConverter.getProjecting().eastNorth2latlonClamped(en);
-    }
-
-    private ICoordinate getShiftedCoord(EastNorth en) {
-        return getShiftedLatLon(en).toCoordinate();
-    }
-
-    private LatLon getShiftedLatLon(ICoordinate latLon) {
-        return getShiftedLatLon(Main.getProjection().latlon2eastNorth(new LatLon(latLon)));
-    }
-
-
-    private final TileSet nullTileSet = new TileSet();
-
-    /**
-     * This is a rectangular range of tiles.
-     */
-    private static class TileRange {
-        int minX;
-        int maxX;
-        int minY;
-        int maxY;
-        int zoom;
-
-        private TileRange() {
-        }
-
-        protected TileRange(TileXY t1, TileXY t2, int zoom) {
-            minX = (int) Math.floor(Math.min(t1.getX(), t2.getX()));
-            minY = (int) Math.floor(Math.min(t1.getY(), t2.getY()));
-            maxX = (int) Math.ceil(Math.max(t1.getX(), t2.getX()));
-            maxY = (int) Math.ceil(Math.max(t1.getY(), t2.getY()));
-            this.zoom = zoom;
-        }
-
-        protected double tilesSpanned() {
-            return Math.sqrt(1.0 * this.size());
-        }
-
-        protected int size() {
-            int xSpan = maxX - minX + 1;
-            int ySpan = maxY - minY + 1;
-            return xSpan * ySpan;
-        }
-
-        /**
-         * Gets a stream of all tile positions in this set
-         * @return A stream of all positions
-         */
-        public Stream<TilePosition> tilePositions() {
-            if (zoom == 0) {
-                return Stream.empty();
-            } else {
-                return IntStream.rangeClosed(minX, maxX).mapToObj(
-                        x -> IntStream.rangeClosed(minY, maxY).mapToObj(y -> new TilePosition(x, y, zoom))
-                        ).flatMap(Function.identity());
-            }
-        }
-    }
-
-    /**
-     * The position of a single tile.
-     * @author Michael Zangl
-     * @since xxx
-     */
-    private static class TilePosition {
-        private final int x;
-        private final int y;
-        private final int zoom;
-        TilePosition(int x, int y, int zoom) {
-            super();
-            this.x = x;
-            this.y = y;
-            this.zoom = zoom;
-        }
-
-        TilePosition(Tile tile) {
-            this(tile.getXtile(), tile.getYtile(), tile.getZoom());
-        }
-
-        /**
-         * @return the x position
-         */
-        public int getX() {
-            return x;
-        }
-
-        /**
-         * @return the y position
-         */
-        public int getY() {
-            return y;
-        }
-
-        /**
-         * @return the zoom
-         */
-        public int getZoom() {
-            return zoom;
-        }
-
-        @Override
-        public String toString() {
-            return "TilePosition [x=" + x + ", y=" + y + ", zoom=" + zoom + "]";
-        }
-    }
-
-    private class TileSet extends TileRange {
-
-        protected TileSet(TileXY t1, TileXY t2, int zoom) {
-            super(t1, t2, zoom);
-            sanitize();
-        }
-
-        /**
-         * null tile set
-         */
-        private TileSet() {
-            // default
-        }
-
-        protected void sanitize() {
-            if (minX < tileSource.getTileXMin(zoom)) {
-                minX = tileSource.getTileXMin(zoom);
-            }
-            if (minY < tileSource.getTileYMin(zoom)) {
-                minY = tileSource.getTileYMin(zoom);
-            }
-            if (maxX > tileSource.getTileXMax(zoom)) {
-                maxX = tileSource.getTileXMax(zoom);
-            }
-            if (maxY > tileSource.getTileYMax(zoom)) {
-                maxY = tileSource.getTileYMax(zoom);
-            }
-        }
-
-        private boolean tooSmall() {
-            return this.tilesSpanned() < 2.1;
-        }
-
-        private boolean tooLarge() {
-            return insane() || this.tilesSpanned() > 20;
-        }
-
-        private boolean insane() {
-            return tileCache == null || size() > tileCache.getCacheSize();
-        }
-
-        /**
-         * Get all tiles represented by this TileSet that are already in the tileCache.
-         */
-        private List<Tile> allExistingTiles() {
-            return allTiles(p -> getTile(p));
-        }
-
-        private List<Tile> allTilesCreate() {
-            return allTiles(p -> getOrCreateTile(p));
-        }
-
-        private List<Tile> allTiles(Function<TilePosition, Tile> mapper) {
-            return tilePositions().map(mapper).filter(Objects::nonNull).collect(Collectors.toList());
-        }
-
-        @Override
-        public Stream<TilePosition> tilePositions() {
-            if (this.insane()) {
-                // Tileset is either empty or too large
-                return Stream.empty();
-            } else {
-                return super.tilePositions();
-            }
-        }
-
-        private List<Tile> allLoadedTiles() {
-            return allExistingTiles().stream().filter(Tile::isLoaded).collect(Collectors.toList());
-        }
-
-        /**
-         * @return comparator, that sorts the tiles from the center to the edge of the current screen
-         */
-        private Comparator<Tile> getTileDistanceComparator() {
-            final int centerX = (int) Math.ceil((minX + maxX) / 2d);
-            final int centerY = (int) Math.ceil((minY + maxY) / 2d);
-            return Comparator.comparingInt(t -> Math.abs(t.getXtile() - centerX) + Math.abs(t.getYtile() - centerY));
-        }
-
-        private void loadAllTiles(boolean force) {
-            if (!getDisplaySettings().isAutoLoad() && !force)
-                return;
-            List<Tile> allTiles = allTilesCreate();
-            allTiles.sort(getTileDistanceComparator());
-            for (Tile t : allTiles) {
-                loadTile(t, force);
-            }
-        }
-
-        private void loadAllErrorTiles(boolean force) {
-            if (!getDisplaySettings().isAutoLoad() && !force)
-                return;
-            for (Tile t : this.allTilesCreate()) {
-                if (t.hasError()) {
-                    tileLoader.createTileLoaderJob(t).submit(force);
-                }
-            }
-        }
-
-        /**
-         * Call the given paint method for all tiles in this tile set.
-         * <p>
-         * Uses a parallel stream.
-         * @param visitor A visitor to call for each tile.
-         * @param missed a consumer to call for each missed tile.
-         */
-        public void visitTiles(Consumer<Tile> visitor, Consumer<TilePosition> missed) {
-            tilePositions().parallel().forEach(tp -> visitTilePosition(visitor, tp, missed));
-        }
-
-        private void visitTilePosition(Consumer<Tile> visitor, TilePosition tp, Consumer<TilePosition> missed) {
-            Tile tile = getTile(tp);
-            if (tile == null) {
-                missed.accept(tp);
-            } else {
-                visitor.accept(tile);
-            }
-        }
-
-        @Override
-        public String toString() {
-            return getClass().getName() + ": zoom: " + zoom + " X(" + minX + ", " + maxX + ") Y(" + minY + ", " + maxY + ") size: " + size();
-        }
-    }
-
-    /**
-     * Create a TileSet by EastNorth bbox taking a layer shift in account
-     * @param topLeft top-left lat/lon
-     * @param botRight bottom-right lat/lon
-     * @param zoom zoom level
-     * @return the tile set
-     * @since 10651
-     */
-    protected TileSet getTileSet(EastNorth topLeft, EastNorth botRight, int zoom) {
-        return getTileSet(getShiftedLatLon(topLeft), getShiftedLatLon(botRight), zoom);
-    }
-
-    /**
-     * Create a TileSet by known LatLon bbox without layer shift correction
-     * @param topLeft top-left lat/lon
-     * @param botRight bottom-right lat/lon
-     * @param zoom zoom level
-     * @return the tile set
-     * @since 10651
-     */
-    protected TileSet getTileSet(LatLon topLeft, LatLon botRight, int zoom) {
-        if (zoom == 0)
-            return new TileSet();
-
-        TileXY t1 = tileSource.latLonToTileXY(topLeft.toCoordinate(), zoom);
-        TileXY t2 = tileSource.latLonToTileXY(botRight.toCoordinate(), zoom);
-        return new TileSet(t1, t2, zoom);
-    }
-
-    private static class TileSetInfo {
-        public boolean hasVisibleTiles;
-        public boolean hasOverzoomedTiles;
-        public boolean hasLoadingTiles;
-    }
-
-    private static <S extends AbstractTMSTileSource> TileSetInfo getTileSetInfo(AbstractTileSourceLayer<S>.TileSet ts) {
-        List<Tile> allTiles = ts.allExistingTiles();
-        TileSetInfo result = new TileSetInfo();
-        result.hasLoadingTiles = allTiles.size() < ts.size();
-        for (Tile t : allTiles) {
-            if ("no-tile".equals(t.getValue("tile-info"))) {
-                result.hasOverzoomedTiles = true;
-            }
-
-            if (t.isLoaded()) {
-                if (!t.hasError()) {
-                    result.hasVisibleTiles = true;
-                }
-            } else if (t.isLoading()) {
-                result.hasLoadingTiles = true;
-            }
-        }
-        return result;
-    }
-
-    private class DeepTileSet {
-        private final ProjectionBounds bounds;
-        private final int minZoom, maxZoom;
-        private final TileSet[] tileSets;
-        private final TileSetInfo[] tileSetInfos;
-
-        @SuppressWarnings("unchecked")
-        DeepTileSet(ProjectionBounds bounds, int minZoom, int maxZoom) {
-            this.bounds = bounds;
-            this.minZoom = minZoom;
-            this.maxZoom = maxZoom;
-            this.tileSets = new AbstractTileSourceLayer.TileSet[maxZoom - minZoom + 1];
-            this.tileSetInfos = new TileSetInfo[maxZoom - minZoom + 1];
-        }
-
-        public TileSet getTileSet(int zoom) {
-            if (zoom < minZoom)
-                return nullTileSet;
-            synchronized (tileSets) {
-                TileSet ts = tileSets[zoom-minZoom];
-                if (ts == null) {
-                    ts = AbstractTileSourceLayer.this.getTileSet(bounds.getMin(), bounds.getMax(), zoom);
-                    tileSets[zoom-minZoom] = ts;
-                }
-                return ts;
-            }
-        }
-
-        public TileSetInfo getTileSetInfo(int zoom) {
-            if (zoom < minZoom)
-                return new TileSetInfo();
-            synchronized (tileSetInfos) {
-                TileSetInfo tsi = tileSetInfos[zoom-minZoom];
-                if (tsi == null) {
-                    tsi = AbstractTileSourceLayer.getTileSetInfo(getTileSet(zoom));
-                    tileSetInfos[zoom-minZoom] = tsi;
-                }
-                return tsi;
-            }
-        }
-    }
-
-    @Override
-    public void paint(Graphics2D g, MapView mv, Bounds bounds) {
-        // old and unused.
-    }
-
-    private void drawInViewArea(Graphics2D g, MapView mv, ProjectionBounds pb) {
-        int zoom = currentZoomLevel;
-        if (getDisplaySettings().isAutoZoom()) {
-            zoom = getBestZoom();
-        }
-
-        DeepTileSet dts = new DeepTileSet(pb, getMinZoomLvl(), zoom);
-        TileSet ts = dts.getTileSet(zoom);
-
-        int displayZoomLevel = zoom;
-
-        boolean noTilesAtZoom = false;
-        if (getDisplaySettings().isAutoZoom() && getDisplaySettings().isAutoLoad()) {
-            // Auto-detection of tilesource maxzoom (currently fully works only for Bing)
-            TileSetInfo tsi = dts.getTileSetInfo(zoom);
-            if (!tsi.hasVisibleTiles && (!tsi.hasLoadingTiles || tsi.hasOverzoomedTiles)) {
-                noTilesAtZoom = true;
-            }
-            // Find highest zoom level with at least one visible tile
-            for (int tmpZoom = zoom; tmpZoom > dts.minZoom; tmpZoom--) {
-                if (dts.getTileSetInfo(tmpZoom).hasVisibleTiles) {
-                    displayZoomLevel = tmpZoom;
-                    break;
-                }
-            }
-            // Do binary search between currentZoomLevel and displayZoomLevel
-            while (zoom > displayZoomLevel && !tsi.hasVisibleTiles && tsi.hasOverzoomedTiles) {
-                zoom = (zoom + displayZoomLevel)/2;
-                tsi = dts.getTileSetInfo(zoom);
-            }
-
-            setZoomLevel(zoom);
-
-            // If all tiles at displayZoomLevel is loaded, load all tiles at next zoom level
-            // to make sure there're really no more zoom levels
-            // loading is done in the next if section
-            if (zoom == displayZoomLevel && !tsi.hasLoadingTiles && zoom < dts.maxZoom) {
-                zoom++;
-                tsi = dts.getTileSetInfo(zoom);
-            }
-            // When we have overzoomed tiles and all tiles at current zoomlevel is loaded,
-            // load tiles at previovus zoomlevels until we have all tiles on screen is loaded.
-            // loading is done in the next if section
-            while (zoom > dts.minZoom && tsi.hasOverzoomedTiles && !tsi.hasLoadingTiles) {
-                zoom--;
-                tsi = dts.getTileSetInfo(zoom);
-            }
-            ts = dts.getTileSet(zoom);
-        } else if (getDisplaySettings().isAutoZoom()) {
-            setZoomLevel(zoom);
-        }
-
-        // Too many tiles... refuse to download
-        if (!ts.tooLarge()) {
-            //Main.debug("size: " + ts.size() + " spanned: " + ts.tilesSpanned());
-            ts.loadAllTiles(false);
-        }
-
-        if (displayZoomLevel != zoom) {
-            ts = dts.getTileSet(displayZoomLevel);
-        }
-
-        g.setColor(Color.DARK_GRAY);
-
-        List<Tile> missedTiles = this.paintTileImages(g, ts);
-        int[] otherZooms = {-1, 1, -2, 2, -3, -4, -5};
-        for (int zoomOffset : otherZooms) {
-            if (!getDisplaySettings().isAutoZoom()) {
-                break;
-            }
-            int newzoom = displayZoomLevel + zoomOffset;
-            if (newzoom < getMinZoomLvl() || newzoom > getMaxZoomLvl()) {
-                continue;
-            }
-            if (missedTiles.isEmpty()) {
-                break;
-            }
-            List<Tile> newlyMissedTiles = new LinkedList<>();
-            for (Tile missed : missedTiles) {
-                if ("no-tile".equals(missed.getValue("tile-info")) && zoomOffset > 0) {
-                    // Don't try to paint from higher zoom levels when tile is overzoomed
-                    newlyMissedTiles.add(missed);
-                    continue;
-                }
-                Tile t2 = tempCornerTile(missed);
-                TileSet ts2 = getTileSet(
-                        getShiftedLatLon(tileSource.tileXYToLatLon(missed)),
-                        getShiftedLatLon(tileSource.tileXYToLatLon(t2)),
-                        newzoom);
-                // Instantiating large TileSets is expensive.  If there
-                // are no loaded tiles, don't bother even trying.
-                if (ts2.allLoadedTiles().isEmpty()) {
-                    newlyMissedTiles.add(missed);
-                    continue;
-                }
-                if (ts2.tooLarge()) {
-                    continue;
-                }
-                newlyMissedTiles.addAll(this.paintTileImages(g, ts2, newzoom, missed));
-            }
-            missedTiles = newlyMissedTiles;
-        }
-        if (Main.isDebugEnabled() && !missedTiles.isEmpty()) {
-            Main.debug("still missed "+missedTiles.size()+" in the end");
-        }
-        g.setColor(Color.red);
-        g.setFont(InfoFont);
-
-        // The current zoom tileset should have all of its tiles due to the loadAllTiles(), unless it to tooLarge()
-        for (Tile t : ts.allExistingTiles()) {
-            this.paintTileText(ts, t, g, mv, displayZoomLevel, t);
-        }
-
-        EastNorth min = pb.getMin();
-        EastNorth max = pb.getMax();
-        attribution.paintAttribution(g, mv.getWidth(), mv.getHeight(), getShiftedCoord(min), getShiftedCoord(max),
-                displayZoomLevel, this);
-
-        //g.drawString("currentZoomLevel=" + currentZoomLevel, 120, 120);
-        g.setColor(Color.lightGray);
-
-        if (ts.insane()) {
-            myDrawString(g, tr("zoom in to load any tiles"), 120, 120);
-        } else if (ts.tooLarge()) {
-            myDrawString(g, tr("zoom in to load more tiles"), 120, 120);
-        } else if (!getDisplaySettings().isAutoZoom() && ts.tooSmall()) {
-            myDrawString(g, tr("increase tiles zoom level (change resolution) to see more detail"), 120, 120);
-        }
-
-        if (noTilesAtZoom) {
-            myDrawString(g, tr("No tiles at this zoom level"), 120, 120);
-        }
-        if (Main.isDebugEnabled()) {
-            myDrawString(g, tr("Current zoom: {0}", currentZoomLevel), 50, 140);
-            myDrawString(g, tr("Display zoom: {0}", displayZoomLevel), 50, 155);
-            myDrawString(g, tr("Pixel scale: {0}", getScaleFactor(currentZoomLevel)), 50, 170);
-            myDrawString(g, tr("Best zoom: {0}", getBestZoom()), 50, 185);
-            myDrawString(g, tr("Estimated cache size: {0}", estimateTileCacheSize()), 50, 200);
-            if (tileLoader instanceof TMSCachedTileLoader) {
-                TMSCachedTileLoader cachedTileLoader = (TMSCachedTileLoader) tileLoader;
-                int offset = 200;
-                for (String part: cachedTileLoader.getStats().split("\n")) {
-                    offset += 15;
-                    myDrawString(g, tr("Cache stats: {0}", part), 50, offset);
-                }
-            }
-        }
-    }
-
-    /**
-     * Returns tile for a pixel position.<p>
-     * This isn't very efficient, but it is only used when the user right-clicks on the map.
-     * @param px pixel X coordinate
-     * @param py pixel Y coordinate
-     * @return Tile at pixel position
-     */
-    private Tile getTileForPixelpos(int px, int py) {
-        if (Main.isDebugEnabled()) {
-            Main.debug("getTileForPixelpos("+px+", "+py+')');
-        }
-        MapView mv = Main.map.mapView;
-        Point clicked = new Point(px, py);
-        EastNorth topLeft = mv.getEastNorth(0, 0);
-        EastNorth botRight = mv.getEastNorth(mv.getWidth(), mv.getHeight());
-        int z = currentZoomLevel;
-        TileSet ts = getTileSet(topLeft, botRight, z);
-
-        if (!ts.tooLarge()) {
-            ts.loadAllTiles(false); // make sure there are tile objects for all tiles
-        }
-        Stream<Tile> clickedTiles = ts.allExistingTiles().stream()
-                .filter(t -> coordinateConverter.getRectangleForTile(t).contains(clicked));
-        if (Main.isTraceEnabled()) {
-            clickedTiles = clickedTiles.peek(t -> Main.trace("Clicked on tile: " + t.getXtile() + ' ' + t.getYtile() +
-                    " currentZoomLevel: " + currentZoomLevel));
-        }
-        return clickedTiles.findAny().orElse(null);
-    }
-
     @Override
     public Action[] getMenuEntries() {
         ArrayList<Action> actions = new ArrayList<>();
@@ -1803,16 +537,12 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
     }
 
     public Action[] getLayerListEntries() {
-        return new Action[] {
-            LayerListDialog.getInstance().createActivateLayerAction(this),
-            LayerListDialog.getInstance().createShowHideLayerAction(),
-            LayerListDialog.getInstance().createDeleteLayerAction(),
-            SeparatorLayerAction.INSTANCE,
-            // color,
-            new OffsetAction(),
-            new RenameLayerAction(this.getAssociatedFile(), this),
-            SeparatorLayerAction.INSTANCE
-        };
+        return new Action[] { LayerListDialog.getInstance().createActivateLayerAction(this),
+                LayerListDialog.getInstance().createShowHideLayerAction(),
+                LayerListDialog.getInstance().createDeleteLayerAction(), SeparatorLayerAction.INSTANCE,
+                // color,
+                new OffsetAction(), new RenameLayerAction(this.getAssociatedFile(), this),
+                SeparatorLayerAction.INSTANCE };
     }
 
     /**
@@ -1821,25 +551,25 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
      */
     public Action[] getCommonEntries() {
         return new Action[] {
-            new AutoLoadTilesAction(),
-            new AutoZoomAction(),
-            new ShowErrorsAction(),
-            new IncreaseZoomAction(),
-            new DecreaseZoomAction(),
-            new ZoomToBestAction(),
-            new ZoomToNativeLevelAction(),
-            new FlushTileCacheAction(),
-            new LoadErroneusTilesAction(),
-            new LoadAllTilesAction()
-        };
+                new AutoLoadTilesAction(),
+                new AutoZoomAction(),
+                new ShowErrorsAction() };
+    }
+
+    private List<Action> getMapViewEntries(MapView mv) {
+        TileSourcePainter<T> painter = painters.get(mv);
+        return painter.getMenuEntries();
     }
 
     @Override
     public String getToolTipText() {
+        String currentZoomLevel = painters.values().stream().findAny().map(TileSourcePainter::getZoomString).orElse("?");
         if (getDisplaySettings().isAutoLoad()) {
-            return tr("{0} ({1}), automatically downloading in zoom {2}", this.getClass().getSimpleName(), getName(), currentZoomLevel);
+            return tr("{0} ({1}), automatically downloading in zoom {2}", this.getClass().getSimpleName(), getName(),
+                    currentZoomLevel);
         } else {
-            return tr("{0} ({1}), downloading in zoom {2}", this.getClass().getSimpleName(), getName(), currentZoomLevel);
+            return tr("{0} ({1}), downloading in zoom {2}", this.getClass().getSimpleName(), getName(),
+                    currentZoomLevel);
         }
     }
 
@@ -1853,6 +583,12 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
         return false;
     }
 
+    @Override
+    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
+        // never called, we use a custom painter
+        throw new UnsupportedOperationException();
+    }
+
     /**
      * Task responsible for precaching imagery along the gpx track
      *
@@ -1868,10 +604,12 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
          */
         public PrecacheTask(ProgressMonitor progressMonitor) {
             this.progressMonitor = progressMonitor;
+            // TODO
+            T tileSource = null;
             this.tileLoader = getTileLoaderFactory().makeTileLoader(this, getHeaders(tileSource));
             if (this.tileLoader instanceof TMSCachedTileLoader) {
-                ((TMSCachedTileLoader) this.tileLoader).setDownloadExecutor(
-                        TMSCachedTileLoader.getNewThreadPoolExecutor("Precache downloader"));
+                ((TMSCachedTileLoader) this.tileLoader)
+                        .setDownloadExecutor(TMSCachedTileLoader.getNewThreadPoolExecutor("Precache downloader"));
             }
         }
 
@@ -1929,13 +667,15 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
      * @param bufferY how many units in current Coordinate Reference System to cover in Y axis in both sides
      * @return precache task representing download task
      */
-    public AbstractTileSourceLayer<T>.PrecacheTask downloadAreaToCache(final ProgressMonitor progressMonitor, List<LatLon> points,
-            double bufferX, double bufferY) {
+    public AbstractTileSourceLayer<T>.PrecacheTask downloadAreaToCache(final ProgressMonitor progressMonitor,
+            List<LatLon> points, double bufferX, double bufferY) {
         PrecacheTask precacheTask = new PrecacheTask(progressMonitor);
         final Set<Tile> requestedTiles = new ConcurrentSkipListSet<>(
                 (o1, o2) -> String.CASE_INSENSITIVE_ORDER.compare(o1.getKey(), o2.getKey()));
-        for (LatLon point: points) {
-
+        for (LatLon point : points) {
+            //TODO
+            TileSource tileSource = null;
+            int currentZoomLevel = 0;
             TileXY minTile = tileSource.latLonToTileXY(point.lat() - bufferY, point.lon() - bufferX, currentZoomLevel);
             TileXY curTile = tileSource.latLonToTileXY(point.toCoordinate(), currentZoomLevel);
             TileXY maxTile = tileSource.latLonToTileXY(point.lat() + bufferY, point.lon() + bufferX, currentZoomLevel);
@@ -1957,7 +697,7 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
         precacheTask.progressMonitor.setTicksCount(requestedTiles.size());
 
         TileLoader loader = precacheTask.getTileLoader();
-        for (Tile t: requestedTiles) {
+        for (Tile t : requestedTiles) {
             loader.createTileLoaderJob(t).submit();
         }
         return precacheTask;
@@ -1979,51 +719,12 @@ implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi
         adjustAction.destroy();
     }
 
-    private class TileSourcePainter extends CompatibilityModeLayerPainter {
-        /**
-         * The memory handle that will hold our tile source.
-         */
-        private MemoryHandle<?> memory;
-
-        @Override
-        public void paint(MapViewGraphics graphics) {
-            allocateCacheMemory();
-            if (memory != null) {
-                doPaint(graphics);
-            }
-        }
-
-        private void doPaint(MapViewGraphics graphics) {
-            ProjectionBounds pb = graphics.getClipBounds().getProjectionBounds();
-
-            drawInViewArea(graphics.getDefaultGraphics(), graphics.getMapView(), pb);
-        }
-
-        private void allocateCacheMemory() {
-            if (memory == null) {
-                MemoryManager manager = MemoryManager.getInstance();
-                if (manager.isAvailable(getEstimatedCacheSize())) {
-                    try {
-                        memory = manager.allocateMemory("tile source layer", getEstimatedCacheSize(), Object::new);
-                    } catch (NotEnoughMemoryException e) {
-                        Main.warn("Could not allocate tile source memory", e);
-                    }
-                }
-            }
-        }
-
-        protected long getEstimatedCacheSize() {
-            return 4L * tileSource.getTileSize() * tileSource.getTileSize() * estimateTileCacheSize();
-        }
-
-        @Override
-        public void detachFromMapView(MapViewEvent event) {
-            event.getMapView().removeMouseListener(adapter);
-            MapView.removeZoomChangeListener(AbstractTileSourceLayer.this);
-            super.detachFromMapView(event);
-            if (memory != null) {
-                memory.free();
-            }
-        }
+    /**
+     * A {@link TileSourcePainter} notifies us of a dispatch
+     * @param tileSourcePainter The painter.
+     */
+    public void detach(TileSourcePainter<T> tileSourcePainter) {
+        GuiHelper.assertCallFromEdt();
+        painters.entrySet().removeIf(e -> e.getValue().equals(tileSourcePainter));
     }
 }
diff --git a/src/org/openstreetmap/josm/gui/layer/TMSLayer.java b/src/org/openstreetmap/josm/gui/layer/TMSLayer.java
index bf833ff..e220828 100644
--- a/src/org/openstreetmap/josm/gui/layer/TMSLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/TMSLayer.java
@@ -22,6 +22,8 @@ import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
 import org.openstreetmap.josm.data.preferences.BooleanProperty;
 import org.openstreetmap.josm.data.preferences.IntegerProperty;
 import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.gui.layer.NativeScaleLayer.ScaleList;
+import org.openstreetmap.josm.gui.layer.imagery.TileSourcePainter;
 
 /**
  * Class that displays a slippy map layer.
@@ -32,7 +34,7 @@ import org.openstreetmap.josm.data.projection.Projection;
  * @author Upliner &lt;upliner@gmail.com&gt;
  * @since 3715
  */
-public class TMSLayer extends AbstractCachedTileSourceLayer<TMSTileSource> implements NativeScaleLayer {
+public class TMSLayer extends AbstractCachedTileSourceLayer<TMSTileSource> {
     private static final String CACHE_REGION_NAME = "TMS";
 
     private static final String PREFERENCE_PREFIX = "imagery.tms";
@@ -57,32 +59,6 @@ public class TMSLayer extends AbstractCachedTileSourceLayer<TMSTileSource> imple
         super(info);
     }
 
-    /**
-     * Creates and returns a new TileSource instance depending on the {@link ImageryType}
-     * of the passed ImageryInfo object.
-     *
-     * If no appropriate TileSource is found, null is returned.
-     * Currently supported ImageryType are {@link ImageryType#TMS},
-     * {@link ImageryType#BING}, {@link ImageryType#SCANEX}.
-     *
-     *
-     * @param info imagery info
-     * @return a new TileSource instance or null if no TileSource for the ImageryInfo/ImageryType could be found.
-     * @throws IllegalArgumentException if url from imagery info is null or invalid
-     */
-    @Override
-    protected TMSTileSource getTileSource(ImageryInfo info) {
-        return getTileSourceStatic(info, () -> {
-            Main.debug("Attribution loaded, running loadAllErrorTiles");
-            this.loadAllErrorTiles(false);
-        });
-    }
-
-    @Override
-    public final boolean isProjectionSupported(Projection proj) {
-        return "EPSG:3857".equals(proj.toCode()) || "EPSG:4326".equals(proj.toCode());
-    }
-
     @Override
     public final String nameSupportedProjections() {
         return tr("EPSG:4326 and Mercator projection are supported");
@@ -148,10 +124,6 @@ public class TMSLayer extends AbstractCachedTileSourceLayer<TMSTileSource> imple
         return AbstractCachedTileSourceLayer.getCache(CACHE_REGION_NAME);
     }
 
-    @Override
-    public ScaleList getNativeScales() {
-        return nativeScaleList;
-    }
 
     private static ScaleList initNativeScaleList() {
         Collection<Double> scales = new ArrayList<>(AbstractTileSourceLayer.MAX_ZOOM);
@@ -161,4 +133,26 @@ public class TMSLayer extends AbstractCachedTileSourceLayer<TMSTileSource> imple
         }
         return new ScaleList(scales);
     }
+
+    @Override
+    protected TileSourcePainter<TMSTileSource> createMapViewPainter(MapViewEvent event) {
+        return new TileSourcePainter<TMSTileSource>(this, event.getMapView()) {
+            @Override
+            public final boolean isProjectionSupported(Projection proj) {
+                return "EPSG:3857".equals(proj.toCode()) || "EPSG:4326".equals(proj.toCode());
+            }
+        //  TODO  @Override
+//          public ScaleList getNativeScales() {
+//              return nativeScaleList;
+//          }
+
+            @Override
+            protected TMSTileSource generateTileSource(AbstractTileSourceLayer<TMSTileSource> layer) {
+                return getTileSourceStatic(info, () -> {
+                    Main.debug("Attribution loaded, running loadAllErrorTiles");
+                    loadAllErrorTiles(false);
+                });
+            }
+        };
+    }
  }
diff --git a/src/org/openstreetmap/josm/gui/layer/WMSLayer.java b/src/org/openstreetmap/josm/gui/layer/WMSLayer.java
index acf27c6..5c8fbf2 100644
--- a/src/org/openstreetmap/josm/gui/layer/WMSLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/WMSLayer.java
@@ -6,6 +6,7 @@ import static org.openstreetmap.josm.tools.I18n.tr;
 import java.awt.event.ActionEvent;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -13,7 +14,6 @@ import java.util.TreeSet;
 
 import javax.swing.AbstractAction;
 import javax.swing.Action;
-import javax.swing.JOptionPane;
 
 import org.apache.commons.jcs.access.CacheAccess;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
@@ -27,8 +27,10 @@ import org.openstreetmap.josm.data.imagery.WMSCachedTileLoader;
 import org.openstreetmap.josm.data.preferences.BooleanProperty;
 import org.openstreetmap.josm.data.preferences.IntegerProperty;
 import org.openstreetmap.josm.data.projection.Projection;
-import org.openstreetmap.josm.gui.ExtendedDialog;
+import org.openstreetmap.josm.data.projection.ProjectionChangeListener;
+import org.openstreetmap.josm.gui.MapView;
 import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings;
+import org.openstreetmap.josm.gui.layer.imagery.TileSourcePainter;
 
 /**
  * This is a layer that grabs the current screen from an WMS server. The data
@@ -115,13 +117,6 @@ public class WMSLayer extends AbstractCachedTileSourceLayer<TemplatedWMSTileSour
     }
 
     @Override
-    public boolean isProjectionSupported(Projection proj) {
-        return supportedProjections == null || supportedProjections.isEmpty() || supportedProjections.contains(proj.toCode()) ||
-                (info.isEpsg4326To3857Supported() && supportedProjections.contains("EPSG:4326")
-                        && "EPSG:3857".equals(Main.getProjection().toCode()));
-    }
-
-    @Override
     public String nameSupportedProjections() {
         StringBuilder ret = new StringBuilder();
         for (String e: supportedProjections) {
@@ -136,31 +131,27 @@ public class WMSLayer extends AbstractCachedTileSourceLayer<TemplatedWMSTileSour
         return ret.substring(0, ret.length()-2) + appendix;
     }
 
-    @Override
-    public void projectionChanged(Projection oldValue, Projection newValue) {
-        // do not call super - we need custom warning dialog
-
-        if (!isProjectionSupported(newValue)) {
-            String message =
-                    "<html><body><p>" + tr("The layer {0} does not support the new projection {1}.", getName(), newValue.toCode()) +
-                    "<p style='width: 450px; position: absolute; margin: 0px;'>" +
-                            tr("Supported projections are: {0}", nameSupportedProjections()) + "</p>" +
-                    "<p>" + tr("Change the projection again or remove the layer.");
-
-            ExtendedDialog warningDialog = new ExtendedDialog(Main.parent, tr("Warning"), new String[]{tr("OK")}).
-                    setContent(message).
-                    setIcon(JOptionPane.WARNING_MESSAGE);
-
-            if (isReprojectionPossible()) {
-                warningDialog.toggleEnable("imagery.wms.projectionSupportWarnings." + tileSource.getBaseUrl());
-            }
-            warningDialog.showDialog();
-        }
-
-        if (!newValue.equals(oldValue)) {
-            tileSource.initProjection(newValue);
-        }
-    }
+//    @Override
+//    public void projectionChanged(Projection oldValue, Projection newValue) {
+//        // do not call super - we need custom warning dialog
+//
+//        if (!isProjectionSupported(newValue)) {
+//            String message =
+//                    "<html><body><p>" + tr("The layer {0} does not support the new projection {1}.", getName(), newValue.toCode()) +
+//                    "<p style='width: 450px; position: absolute; margin: 0px;'>" +
+//                            tr("Supported projections are: {0}", nameSupportedProjections()) + "</p>" +
+//                    "<p>" + tr("Change the projection again or remove the layer.");
+//
+//            ExtendedDialog warningDialog = new ExtendedDialog(Main.parent, tr("Warning"), new String[]{tr("OK")}).
+//                    setContent(message).
+//                    setIcon(JOptionPane.WARNING_MESSAGE);
+//
+//            if (isReprojectionPossible()) {
+//// TODO:               warningDialog.toggleEnable("imagery.wms.projectionSupportWarnings." + tileSource.getBaseUrl());
+//            }
+//            warningDialog.showDialog();
+//        }
+//    }
 
     @Override
     protected Class<? extends TileLoader> getTileLoaderClass() {
@@ -182,4 +173,40 @@ public class WMSLayer extends AbstractCachedTileSourceLayer<TemplatedWMSTileSour
     private boolean isReprojectionPossible() {
         return supportedProjections.contains("EPSG:4326") && "EPSG:3857".equals(Main.getProjection().toCode());
     }
+
+    @Override
+    protected TileSourcePainter<TemplatedWMSTileSource> createMapViewPainter(MapViewEvent event) {
+        return new WMSPainter(this, event.getMapView());
+    }
+
+    private static class WMSPainter extends TileSourcePainter<TemplatedWMSTileSource> {
+        private final ProjectionChangeListener initOnProjectionChange = (oldValue, newValue) -> tileSource.initProjection(newValue);
+        private HashSet<String> supportedProjections;
+
+        public WMSPainter(AbstractTileSourceLayer<TemplatedWMSTileSource> abstractTileSourceLayer, MapView mapView) {
+            super(abstractTileSourceLayer, mapView);
+            Main.addProjectionChangeListener(initOnProjectionChange);
+
+            ImageryInfo info2 = abstractTileSourceLayer.getInfo();
+            supportedProjections = new HashSet<>(info2.getServerProjections());
+            if (info2.isEpsg4326To3857Supported() && supportedProjections.contains("EPSG:4326")) {
+                supportedProjections.add("EPSG:3857");
+            }
+
+            zoom.setZoomBounds(0, zoom.getMaxZoom());
+        }
+
+        @Override
+        public void detachFromMapView(MapViewEvent event) {
+            Main.removeProjectionChangeListener(initOnProjectionChange);
+            super.detachFromMapView(event);
+        }
+
+        @Override
+        public boolean isProjectionSupported(Projection proj) {
+            return supportedProjections == null || supportedProjections.isEmpty() || supportedProjections.contains(proj.toCode());
+
+        }
+
+    }
 }
diff --git a/src/org/openstreetmap/josm/gui/layer/WMTSLayer.java b/src/org/openstreetmap/josm/gui/layer/WMTSLayer.java
index f4cb70c..07ac173 100644
--- a/src/org/openstreetmap/josm/gui/layer/WMTSLayer.java
+++ b/src/org/openstreetmap/josm/gui/layer/WMTSLayer.java
@@ -2,7 +2,6 @@
 package org.openstreetmap.josm.gui.layer;
 
 import java.io.IOException;
-import java.util.Set;
 
 import org.apache.commons.jcs.access.CacheAccess;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
@@ -13,7 +12,10 @@ import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
 import org.openstreetmap.josm.data.imagery.WMSCachedTileLoader;
 import org.openstreetmap.josm.data.imagery.WMTSTileSource;
 import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.data.projection.ProjectionChangeListener;
+import org.openstreetmap.josm.gui.MapView;
 import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings;
+import org.openstreetmap.josm.gui.layer.imagery.TileSourcePainter;
 
 /**
  * WMTS layer based on AbstractTileSourceLayer. Overrides few methods to align WMTS to Tile based computations
@@ -25,7 +27,7 @@ import org.openstreetmap.josm.gui.layer.imagery.TileSourceDisplaySettings;
  * @author Wiktor NiesiobÄ™dzki
  *
  */
-public class WMTSLayer extends AbstractCachedTileSourceLayer<WMTSTileSource> implements NativeScaleLayer {
+public class WMTSLayer extends AbstractCachedTileSourceLayer<WMTSTileSource> {
     private static final String PREFERENCE_PREFIX = "imagery.wmts";
 
     /**
@@ -50,65 +52,33 @@ public class WMTSLayer extends AbstractCachedTileSourceLayer<WMTSTileSource> imp
         return new TileSourceDisplaySettings(PREFERENCE_PREFIX);
     }
 
-    @Override
-    protected WMTSTileSource getTileSource(ImageryInfo info) {
-        try {
-            if (info.getImageryType() == ImageryType.WMTS && info.getUrl() != null) {
-                WMTSTileSource.checkUrl(info.getUrl());
-                WMTSTileSource tileSource = new WMTSTileSource(info);
-                info.setAttribution(tileSource);
-                return tileSource;
-            }
-            return null;
-        } catch (IOException e) {
-            Main.warn(e);
-            throw new IllegalArgumentException(e);
-        }
-    }
-
-    @Override
-    protected int getBestZoom() {
-        if (!Main.isDisplayingMapView())
-            return 0;
-        ScaleList scaleList = getNativeScales();
-        if (scaleList == null) {
-            return getMaxZoomLvl();
-        }
-        Scale snap = scaleList.getSnapScale(Main.map.mapView.getScale(), false);
-        return Math.max(
-                getMinZoomLvl(),
-                Math.min(
-                        snap != null ? snap.getIndex() : getMaxZoomLvl(),
-                        getMaxZoomLvl()
-                        )
-                );
-    }
-
-    @Override
-    protected int getMinZoomLvl() {
-        return 0;
-    }
-
-    @Override
-    public boolean isProjectionSupported(Projection proj) {
-        Set<String> supportedProjections = tileSource.getSupportedProjections();
-        return supportedProjections.contains(proj.toCode());
-    }
-
-    @Override
-    public String nameSupportedProjections() {
-        StringBuilder ret = new StringBuilder();
-        for (String e: tileSource.getSupportedProjections()) {
-            ret.append(e).append(", ");
-        }
-        return ret.length() > 2 ? ret.substring(0, ret.length()-2) : ret.toString();
-    }
-
-    @Override
-    public void projectionChanged(Projection oldValue, Projection newValue) {
-        super.projectionChanged(oldValue, newValue);
-        tileSource.initProjection(newValue);
-    }
+    //  TODO  @Override
+    //    protected int getBestZoom() {
+    //        if (!Main.isDisplayingMapView())
+    //            return 0;
+    //        ScaleList scaleList = getNativeScales();
+    //        if (scaleList == null) {
+    //            return getMaxZoomLvl();
+    //        }
+    //        Scale snap = scaleList.getSnapScale(Main.map.mapView.getScale(), false);
+    //        return Math.max(
+    //                getMinZoomLvl(),
+    //                Math.min(
+    //                        snap != null ? snap.getIndex() : getMaxZoomLvl(),
+    //                        getMaxZoomLvl()
+    //                        )
+    //                );
+    //    }
+
+    //
+    //    @Override
+    //    public String nameSupportedProjections() {
+    //        StringBuilder ret = new StringBuilder();
+    //        for (String e: tileSource.getSupportedProjections()) {
+    //            ret.append(e).append(", ");
+    //        }
+    //        return ret.length() > 2 ? ret.substring(0, ret.length()-2) : ret.toString();
+    //    }
 
     @Override
     protected Class<? extends TileLoader> getTileLoaderClass() {
@@ -127,8 +97,53 @@ public class WMTSLayer extends AbstractCachedTileSourceLayer<WMTSTileSource> imp
         return AbstractCachedTileSourceLayer.getCache(CACHE_REGION_NAME);
     }
 
+    //  TODO  @Override
+    //    public ScaleList getNativeScales() {
+    //        return tileSource.getNativeScales();
+    //    }
+
     @Override
-    public ScaleList getNativeScales() {
-        return tileSource.getNativeScales();
+    protected TileSourcePainter<WMTSTileSource> createMapViewPainter(MapViewEvent event) {
+        return new WMTSPainter(this, event.getMapView());
+    }
+
+    private static class WMTSPainter extends TileSourcePainter<WMTSTileSource> {
+        private final ProjectionChangeListener initOnProjectionChange = (oldValue, newValue) -> tileSource
+                .initProjection(newValue);
+
+        public WMTSPainter(AbstractTileSourceLayer<WMTSTileSource> abstractTileSourceLayer, MapView mapView) {
+            super(abstractTileSourceLayer, mapView);
+            Main.addProjectionChangeListener(initOnProjectionChange);
+
+            zoom.setZoomBounds(0, zoom.getMaxZoom());
+        }
+
+        @Override
+        protected WMTSTileSource generateTileSource(AbstractTileSourceLayer<WMTSTileSource> layer) {
+            try {
+                ImageryInfo layerInfo = layer.getInfo();
+                if (layerInfo.getImageryType() == ImageryType.WMTS && layerInfo.getUrl() != null) {
+                    WMTSTileSource.checkUrl(layerInfo.getUrl());
+                    WMTSTileSource tileSource = new WMTSTileSource(layerInfo);
+                    layerInfo.setAttribution(tileSource);
+                    return tileSource;
+                }
+                return null;
+            } catch (IOException e) {
+                Main.warn(e);
+                throw new IllegalArgumentException(e);
+            }
+        }
+
+        @Override
+        public void detachFromMapView(MapViewEvent event) {
+            Main.removeProjectionChangeListener(initOnProjectionChange);
+            super.detachFromMapView(event);
+        }
+
+        @Override
+        public boolean isProjectionSupported(Projection proj) {
+            return tileSource.getSupportedProjections().contains(proj.toCode());
+        }
     }
 }
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/AbstractTileSourceLoader.java b/src/org/openstreetmap/josm/gui/layer/imagery/AbstractTileSourceLoader.java
new file mode 100644
index 0000000..248cded
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/AbstractTileSourceLoader.java
@@ -0,0 +1,286 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Dimension;
+import java.awt.event.ActionEvent;
+import java.util.function.Predicate;
+
+import javax.swing.AbstractAction;
+
+import org.openstreetmap.gui.jmapviewer.AttributionSupport;
+import org.openstreetmap.gui.jmapviewer.MemoryTileCache;
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.CachedTileLoader;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileCache;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileLoader;
+import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource;
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.NavigatableComponent.ZoomChangeListener;
+import org.openstreetmap.josm.gui.PleaseWaitRunnable;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.gui.layer.imagery.TileForAreaFinder.TileForAreaGetter;
+import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.tools.MemoryManager;
+import org.openstreetmap.josm.tools.MemoryManager.MemoryHandle;
+import org.openstreetmap.josm.tools.MemoryManager.NotEnoughMemoryException;
+
+/**
+ * This class backs the {@link TileSourcePainter} by handling the loading / acces of the tile images
+ * @author Michael Zangl
+ * @param <T> The imagery type to use
+ * @since xxx
+ */
+public abstract class AbstractTileSourceLoader<T extends AbstractTMSTileSource> implements TileForAreaGetter, ZoomChangeListener {
+
+    /*
+     *  use MemoryTileCache instead of tileLoader JCS cache, as tileLoader caches only content (byte[] of image)
+     *  and MemoryTileCache caches whole Tile. This gives huge performance improvement when a lot of tiles are visible
+     *  in MapView (for example - when limiting min zoom in imagery)
+     *
+     *  Use per-layer tileCache instance, as the more layers there are, the more tiles needs to be cached
+     */
+    private final TileCache tileCache; // initialized together with tileSource
+    protected final T tileSource;
+    protected final TileLoader tileLoader;
+    protected final AttributionSupport attribution = new AttributionSupport();
+
+    /**
+     * The memory handle that will hold our tile source.
+     */
+    private MemoryHandle<?> memory;
+
+    protected AbstractTileSourceLoader(AbstractTileSourceLayer<T> layer) {
+        tileSource = generateTileSource(layer);
+        if (tileSource == null) {
+            throw new IllegalArgumentException(tr("Failed to create tile source"));
+        }
+
+        attribution.initialize(tileSource);
+
+        tileLoader = layer.generateTileLoader(tileSource);
+
+        tileCache = new MemoryTileCache(estimateTileCacheSize());
+        MapView.addZoomChangeListener(this);
+    }
+
+    protected T generateTileSource(AbstractTileSourceLayer<T> layer) {
+        return layer.getTileSource();
+    }
+    /**
+     * Check if there are any matching tiles in the given range
+     * @param range The range to check in
+     * @param pred The predicate the tiles need to match
+     * @return If there are such tiles.
+     */
+    public boolean hasTiles(TileRange range, Predicate<Tile> pred) {
+        return range.tilePositions().map(this::getTile).anyMatch(pred);
+    }
+
+    protected Tile getOrCreateTile(TilePosition tilePosition) {
+        Tile tile = getTile(tilePosition);
+        if (tile == null) {
+            tile = new Tile(tileSource, tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
+            tileCache.addTile(tile);
+        }
+
+        if (!tile.isLoaded()) {
+            tile.loadPlaceholderFromCache(tileCache);
+        }
+        return tile;
+    }
+
+    /**
+     * Returns tile at given position.
+     * This can and will return null for tiles that are not already in the cache.
+     * @param tilePosition The position
+     * @return tile at given position
+     */
+    protected Tile getTile(TilePosition tilePosition) {
+        if (!contains(tilePosition)) {
+            return null;
+        } else {
+            return tileCache.getTile(tileSource, tilePosition.getX(), tilePosition.getY(), tilePosition.getZoom());
+        }
+    }
+
+    /**
+     * Check if this tile source contains the given position.
+     * @param position The position
+     * @return <code>true</code> if that positon is contained.
+     */
+    private boolean contains(TilePosition position) {
+        return position.getZoom() >= tileSource.getMinZoom() && position.getZoom() <= tileSource.getMaxZoom()
+                && position.getX() >= tileSource.getTileXMin(position.getZoom())
+                && position.getX() <= tileSource.getTileXMax(position.getZoom())
+                && position.getY() >= tileSource.getTileYMin(position.getZoom())
+                && position.getY() <= tileSource.getTileYMax(position.getZoom());
+    }
+
+    protected void loadTiles(TileRange range, boolean force) {
+        if (force) {
+            if (isTooLarge(range)) {
+                Main.warn("Not downloading all tiles because there are too many tiles on an axis!");
+            } else {
+                range.tilePositionsSorted().filter(this::contains).forEach(t -> loadTile(t, force));
+            }
+        }
+    }
+
+    protected static boolean isTooSmall(TileRange range) {
+        return range.tilesSpanned() < 2;
+    }
+
+    protected boolean isTooLarge(TileRange range) {
+        return range.size() > tileCache.getCacheSize() || range.tilesSpanned() > 20;
+    }
+
+    protected boolean loadTile(TilePosition tile, boolean force) {
+        return loadTile(getOrCreateTile(tile), force);
+    }
+
+    private boolean loadTile(Tile tile, boolean force) {
+        if (tile == null)
+            return false;
+        if (!force && (tile.isLoaded() || tile.hasError() || isOverzoomed(tile)))
+            return false;
+        if (tile.isLoading())
+            return false;
+        tileLoader.createTileLoaderJob(tile).submit(force);
+        return true;
+    }
+
+    @Override
+    public void zoomChanged() {
+        if (tileLoader instanceof TMSCachedTileLoader) {
+            ((TMSCachedTileLoader) tileLoader).cancelOutstandingTasks();
+        }
+    }
+
+    /**
+     * Test if a tile is visible.
+     * @param t The tile to test
+     * @return <code>true</code> if it is visible
+     */
+    public static boolean isVisible(Tile t) {
+        return t != null && t.isLoaded() && !t.hasError();
+    }
+
+    /**
+     * Test if a tile is missing.
+     * @param t The tile to test
+     * @return <code>true</code> if it is loading or not loaded yet.
+     */
+    public static boolean isMissing(Tile t) {
+        return t == null || t.isLoading();
+    }
+
+    /**
+     * Test if a tile is marked as loading.
+     * @param t The tile to test
+     * @return <code>true</code> if it is loading
+     */
+    public static boolean isLoading(Tile t) {
+        return t != null && t.isLoading();
+    }
+
+    /**
+     * Test if a tile is marked as overzoomed.
+     * @param t The tile to test
+     * @return <code>true</code> if it is overzoomed
+     */
+    public static boolean isOverzoomed(Tile t) {
+        return t != null && "no-tile".equals(t.getValue("tile-info"));
+    }
+
+    /**
+     * Reserve the memory for the cache
+     * @return <code>true</code> if it is reserved.
+     */
+    protected boolean allocateCacheMemory() {
+        if (memory == null) {
+            MemoryManager manager = MemoryManager.getInstance();
+            if (manager.isAvailable(getEstimatedCacheSize())) {
+                try {
+                    memory = manager.allocateMemory("tile source layer", getEstimatedCacheSize(), Object::new);
+                } catch (NotEnoughMemoryException e) {
+                    Main.warn("Could not allocate tile source memory", e);
+                }
+            }
+        }
+        return memory != null;
+    }
+
+    /**
+     * Free the cache memeory
+     */
+    protected void freeCacheMemory() {
+        if (memory != null) {
+            memory.free();
+        }
+    }
+
+    protected long getEstimatedCacheSize() {
+        return 4L * tileSource.getTileSize() * tileSource.getTileSize() * estimateTileCacheSize();
+    }
+
+    protected int estimateTileCacheSize() {
+        Dimension screenSize = GuiHelper.getMaximumScreenSize();
+        int height = screenSize.height;
+        int width = screenSize.width;
+        int tileSize = 256; // default tile size
+        if (tileSource != null) {
+            tileSize = tileSource.getTileSize();
+        }
+        // as we can see part of the tile at the top and at the bottom, use Math.ceil(...) + 1 to accommodate for that
+        int visibileTiles = (int) (Math.ceil((double) height / tileSize + 1)
+                * Math.ceil((double) width / tileSize + 1));
+        // add 10% for tiles from different zoom levels
+        // use offset to decide, how many tiles are visible
+        int ret = (int) Math.ceil(Math.pow(2d, AbstractTileSourceLayer.ZOOM_OFFSET.get()) * visibileTiles * 4);
+        Main.info("AbstractTileSourceLayer: estimated visible tiles: {0}, estimated cache size: {1}", visibileTiles,
+                ret);
+        return ret;
+    }
+
+
+    protected class FlushTileCacheAction extends AbstractAction {
+        FlushTileCacheAction() {
+            super(tr("Flush tile cache"));
+            setEnabled(tileLoader instanceof CachedTileLoader);
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent ae) {
+            new PleaseWaitRunnable(tr("Flush tile cache")) {
+                @Override
+                protected void realRun() {
+                    clearTileCache();
+                }
+
+                @Override
+                protected void finish() {
+                    // empty - flush is instaneus
+                }
+
+                @Override
+                protected void cancel() {
+                    // empty - flush is instaneus
+                }
+
+                /**
+                 * Clears the tile cache.
+                 */
+                private void clearTileCache() {
+                    if (tileLoader instanceof CachedTileLoader) {
+                        ((CachedTileLoader) tileLoader).clearCache(tileSource);
+                    }
+                    tileCache.clear();
+                }
+            }.run();
+        }
+    }
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/TextPainter.java b/src/org/openstreetmap/josm/gui/layer/imagery/TextPainter.java
new file mode 100644
index 0000000..580758e
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/TextPainter.java
@@ -0,0 +1,105 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.geom.AffineTransform;
+
+/**
+ * This class handles text painting on the tile source layer.
+ * @author Michael Zangl
+ * @since xxx
+ */
+public class TextPainter {
+    private Graphics2D g;
+    private int debugY;
+    private int overlayY;
+
+    /**
+     * Reset internal state and start.
+     * @param g The graphics to paint on
+     */
+    public void start(Graphics2D g) {
+        this.g = g;
+        debugY = 160;
+        overlayY = 100;
+    }
+
+    /**
+     * Add a debug string
+     * @param debugLine The debug line
+     */
+    public void addDebug(String debugLine) {
+        debugY += drawString(debugLine, 50, debugY, 500);
+    }
+
+    /**
+     * Draw a string onto a given tile.
+     * @param text The text to draw
+     * @param tile The tile to paint on
+     * @param converter A converter to convert the tile to screen coordinates.
+     */
+    public void drawTileString(String text, TilePosition tile, TileCoordinateConverter converter) {
+        AffineTransform transform = converter.getTransformForTile(tile, 0, 0, 0, .5, 1, .5);
+        transform.scale(1.0 / 200, 1.0 / 200);
+        AffineTransform oldTransform = g.getTransform();
+        g.transform(transform);
+        drawString(text, 10, 10, 180);
+        g.setTransform(oldTransform);
+    }
+
+    /**
+     * Add a text overlay for the map.
+     * @param text The text to add.
+     */
+    public void addTextOverlay(String text) {
+        overlayY += drawString(text, 120, overlayY, 500);
+    }
+
+    private int drawString(String text, int x, int y, int width) {
+        String textToDraw = text;
+        int maxLineWidth = 0;
+        int wholeLineWidth = g.getFontMetrics().stringWidth(text);
+        if (wholeLineWidth > width) {
+            // text longer than tile size, split it
+            StringBuilder line = new StringBuilder();
+            StringBuilder ret = new StringBuilder();
+            for (String s: text.split(" ")) {
+                int lineWidth = g.getFontMetrics().stringWidth(line.toString() + s);
+                if (lineWidth > width) {
+                    ret.append(line).append('\n');
+                    line.setLength(0);
+                    lineWidth = g.getFontMetrics().stringWidth(s);
+                }
+                line.append(s).append(' ');
+                maxLineWidth = Math.max(lineWidth, maxLineWidth);
+            }
+            ret.append(line);
+            textToDraw = ret.toString();
+        } else {
+            maxLineWidth = wholeLineWidth;
+        }
+
+        return drawLines(x, y, textToDraw.split("\n"), maxLineWidth);
+    }
+
+    private int drawLines(int x, int y, String[] lines, int maxLineWidth) {
+        int height = g.getFontMetrics().getHeight();
+
+        // background
+        g.setColor(new Color(0, 0, 0, 50));
+        g.fillRect(x - 3, y - height - 1, maxLineWidth + 6, (3 + height) * lines.length + 2);
+
+        int offset = 0;
+        for (String s: lines) {
+            // shadow
+            g.setColor(Color.black);
+            g.drawString(s, x + 1, y + offset + 1);
+            g.setColor(Color.lightGray);
+            g.drawString(s, x, y + offset);
+            offset += height + 3;
+        }
+        return offset;
+    }
+
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/TileCoordinateConverter.java b/src/org/openstreetmap/josm/gui/layer/imagery/TileCoordinateConverter.java
index ff368e5..fc76dce 100644
--- a/src/org/openstreetmap/josm/gui/layer/imagery/TileCoordinateConverter.java
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/TileCoordinateConverter.java
@@ -1,18 +1,21 @@
 // License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.gui.layer.imagery;
 
+import java.awt.Shape;
+import java.awt.geom.AffineTransform;
 import java.awt.geom.Point2D;
-import java.awt.geom.Rectangle2D;
 
 import org.openstreetmap.gui.jmapviewer.Tile;
 import org.openstreetmap.gui.jmapviewer.TileXY;
 import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
 import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.data.Bounds;
 import org.openstreetmap.josm.data.coor.LatLon;
-import org.openstreetmap.josm.data.projection.Projecting;
-import org.openstreetmap.josm.data.projection.ShiftedProjecting;
-import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.MapViewState;
+import org.openstreetmap.josm.gui.MapViewState.MapViewLatLonRectangle;
 import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.tools.Utils;
 
 /**
  * This class handles tile coordinate management and computes their position in the map view.
@@ -20,8 +23,7 @@ import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
  * @since 10651
  */
 public class TileCoordinateConverter {
-    private MapView mapView;
-    private TileSourceDisplaySettings settings;
+    private MapViewState displacedState;
     private TileSource tileSource;
 
     /**
@@ -30,22 +32,13 @@ public class TileCoordinateConverter {
      * @param tileSource The tile source to use when converting coordinates.
      * @param settings displacement settings.
      */
-    public TileCoordinateConverter(MapView mapView, TileSource tileSource, TileSourceDisplaySettings settings) {
-        this.mapView = mapView;
+    public TileCoordinateConverter(MapViewState mapView, TileSource tileSource, TileSourceDisplaySettings settings) {
+        this.displacedState = mapView.shifted(settings.getDisplacement());
         this.tileSource = tileSource;
-        this.settings = settings;
     }
 
-    private MapViewPoint pos(ICoordinate ll) {
-        return mapView.getState().getPointFor(new LatLon(ll)).add(settings.getDisplacement());
-    }
-
-    /**
-     * Gets the projecting instance to use to convert between latlon and eastnorth coordinates.
-     * @return The {@link Projecting} instance.
-     */
-    public Projecting getProjecting() {
-        return new ShiftedProjecting(mapView.getProjection(), settings.getDisplacement());
+    protected MapViewPoint pos(ICoordinate ll) {
+        return displacedState.getPointFor(new LatLon(ll));
     }
 
     /**
@@ -63,11 +56,137 @@ public class TileCoordinateConverter {
      * @param tile The tile
      * @return The positon.
      */
-    public Rectangle2D getRectangleForTile(Tile tile) {
-        ICoordinate c1 = tile.getTileSource().tileXYToLatLon(tile);
-        ICoordinate c2 = tile.getTileSource().tileXYToLatLon(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom());
+    public MapViewLatLonRectangle getAreaForTile(TilePosition tile) {
+        MapViewPoint p1 = tileUV(tile, 0, 0);
+        MapViewPoint p2 = tileUV(tile, 1, 1);
 
-        return pos(c1).rectTo(pos(c2)).getInView();
+        return p1.latLonRectTo(p2);
+    }
+
+    /**
+     * Gets an affine transform that maps image u/v (0..1) space to east/north space.
+     * <p>
+     * You need to scale it by the image size to draw the buffered image.
+     * @param tile
+     * @param u1
+     * @param v1
+     * @param u2
+     * @param v2
+     * @param u3
+     * @param v3
+     * @return the transform
+     */
+    public AffineTransform getTransformForTile(TilePosition tile, double u1, double v1, double u2, double v2, double u3, double v3) {
+        MapViewPoint p1 = tileUV(tile, u1, v1);
+        MapViewPoint p2 = tileUV(tile, u2, v2);
+        MapViewPoint p3 = tileUV(tile, u3, v3);
+
+        // We compute the matrix in a way that p_i.inView is mapped to the corresponding image position.
+        // ( u1 )   ( m00 m01 m02  )   (p1.viewX )
+        // ( v1 ) * ( m10 m11 m12  ) = (p1.viewY )
+        // ( 1  )   (  0   0   1   )   (1        )
+        // ( u2 )   ( m00 m01 m02  )   (p2.viewX )
+        // ( v2 ) * ( m10 m11 m12  ) = (p2.viewY )
+        // ( 1  )   (  0   0   1   )   (1        )
+        // ( u3 )   ( m00 m01 m02  )   (p3.viewX )
+        // ( v3 ) * ( m10 m11 m12  ) = (p3.viewY )
+        // ( 1  )   (  0   0   1   )   (1        )
+
+        // u1 * m00 + v1 * m01 + m02 = p1.viewX
+        // u2 * m00 + v2 * m01 + m02 = p2.viewX
+        // u3 * m00 + v3 * m01 + m02 = p3.viewX
+        // u1 * m10 + v1 * m11 + m12 = p1.viewY
+        // u2 * m10 + v2 * m11 + m12 = p2.viewY
+        // u3 * m10 + v3 * m11 + m12 = p3.viewY
+
+        // u1        * m00 + v1        * m01 + m02 = p1.viewX
+        // (u2 - u1) * m00 + (v2 - v1) * m01       = p2.viewX - p1.viewX
+        // (u3 - u1) * m00 + (v3 - v1) * m01       = p3.viewX - p1.viewX
+
+        // if v2 != v1 and v3 != v1
+        // (u2 - u1) / (v2 - v1) * m00 + m01       = (p2.viewX - p1.viewX) / (v2 - v1)
+        // (u3 - u1) / (v3 - v1) * m00 + m01       = (p3.viewX - p1.viewX) / (v3 - v1)
+
+        // m00 = ((p2.viewX - p1.viewX) / (v2 - v1) - (p3.viewX - p1.viewX) / (v3 - v1)) / ((u2 - u1) / (v2 - v1) - (u3 - u1) / (v3 - v1))
+        // m01 = (p3.viewX - p1.viewX) / (v3 - v1) - (u3 - u1) / (v3 - v1) * m00
+        // m02 = p1.viewX - u1 * m00 + v1 * m01
+
+        // if v2 == v1:
+        // u1        * m00 + v1        * m01 + m02 = p1.viewX
+        // (u2 - u1) * m00 +                       = p2.viewX - p1.viewX
+        // (u3 - u1) * m00 + (v3 - v1) * m01       = p3.viewX - p1.viewX
+
+        // if v3 == v1
+        // u1        * m00 + v1        * m01 + m02 = p1.viewX
+        // (u2 - u1) * m00 + (v2 - v1) * m01       = p2.viewX - p1.viewX
+        // (u3 - u1) * m00 +                       = p3.viewX - p1.viewX
+
+
+        double du2 = u2 - u1;
+        double du3 = u3 - u1;
+        double dv2 = v2 - v1;
+        double dv3 = v3 - v1;
+
+        // x space
+        double p1x = p1.getInView().getX();
+        double p2x = p2.getInView().getX();
+        double p3x = p3.getInView().getX();
+
+        double m00;
+        double m01;
+        if (Utils.equalsEpsilon(0, dv2)) {
+            if (Utils.equalsEpsilon(0, du2) || Utils.equalsEpsilon(0, dv3)) {
+                // unsolveable
+                return new AffineTransform();
+            }
+            m00 = (p2x - p1x) / du2;
+            m01 = (p3x - p1x) / dv3 - du3 / dv3 * m00;
+       } else if (Utils.equalsEpsilon(0, dv3)) {
+            if (Utils.equalsEpsilon(0, du3)) {
+                // unsolveable
+                return new AffineTransform();
+            }
+            m00 = (p3x - p1x) / du3;
+            m01 = (p2x - p1x) / dv2 - du2 / dv2 * m00;
+        } else {
+            m00 = ((p2x - p1x) / dv2 - (p3x - p1x) / dv3) / (du2 / dv2 - du3 / dv3);
+            m01 = (p3x - p1x) / dv3 - du3 / dv3 * m00;
+        }
+        double m02 = p1x - u1 * m00 + v1 * m01;
+
+        // y space
+        double p1y = p1.getInView().getY();
+        double p2y = p2.getInView().getY();
+        double p3y = p3.getInView().getY();
+        double m10;
+        double m11;
+        if (Utils.equalsEpsilon(0, dv2)) {
+            m10 = (p2y - p1y) / du2;
+            m11 = (p3y - p1y) / dv3 - du3 / dv3 * m10;
+       } else if (Utils.equalsEpsilon(0, dv3)) {
+            m10 = (p3y - p1y) / du3;
+            m11 = (p2y - p1y) / dv2 - du2 / dv2 * m10;
+        } else {
+            m10 = ((p2y - p1y) / dv2 - (p3y - p1y) / dv3) / (du2 / dv2 - du3 / dv3);
+            m11 = (p3y - p1y) / dv3 - du3 / dv3 * m10;
+        }
+        double m12 = p1y - u1 * m10 + v1 * m11;
+
+        return new AffineTransform(new double[] {
+                m00, m10, m01, m11, m02, m12
+        });
+    }
+
+    private MapViewPoint tileUV(TilePosition tile, double u, double v) {
+        ICoordinate tileLatLon = tileSource.tileXYToLatLon(tile.getX(), tile.getY(), tile.getZoom());
+        if (Utils.equalsEpsilon(0, u) && Utils.equalsEpsilon(0, v)) {
+            return pos(tileLatLon);
+        } else {
+            ICoordinate nextTile = tileSource.tileXYToLatLon(tile.getX() + 1, tile.getY() + 1, tile.getZoom());
+            return displacedState.getPointFor(new LatLon(
+                    (1 - v) * tileLatLon.getLat() + v * nextTile.getLat(),
+                    (1 - u) * tileLatLon.getLon() + u * nextTile.getLon()));
+        }
     }
 
     /**
@@ -76,14 +195,62 @@ public class TileCoordinateConverter {
      * @return average number of screen pixels per tile pixel
      */
     public double getScaleFactor(int zoom) {
-        LatLon topLeft = mapView.getLatLon(0, 0);
-        LatLon botRight = mapView.getLatLon(mapView.getWidth(), mapView.getHeight());
-        TileXY t1 = tileSource.latLonToTileXY(topLeft.toCoordinate(), zoom);
-        TileXY t2 = tileSource.latLonToTileXY(botRight.toCoordinate(), zoom);
-
-        int screenPixels = mapView.getWidth()*mapView.getHeight();
-        double tilePixels = Math.abs((t2.getY()-t1.getY())*(t2.getX()-t1.getX())*tileSource.getTileSize()*tileSource.getTileSize());
-        if (screenPixels == 0 || tilePixels == 0) return 1;
-        return screenPixels/tilePixels;
+        Bounds area = displacedState.getViewArea().getCornerBounds();
+        TileXY t1 = tileSource.latLonToTileXY(area.getMin().toCoordinate(), zoom);
+        TileXY t2 = tileSource.latLonToTileXY(area.getMax().toCoordinate(), zoom);
+
+        double screenPixels = displacedState.getViewWidth() * displacedState.getViewHeight();
+        int tileSize = tileSource.getTileSize();
+        double tilePixels = Math.abs((t2.getY() - t1.getY()) * (t2.getX() - t1.getX()) * tileSize * tileSize);
+        if (screenPixels < 1e-10 || tilePixels < 1e-10) {
+            return 1;
+        } else {
+            return screenPixels / tilePixels;
+        }
+    }
+
+    /**
+     * Get the tiles in view at the given zoom level.
+     * @param zoom The zoom level
+     * @return The tiles that are in the view.
+     */
+    public TileRange getViewAtZoom(int zoom) {
+        Bounds view = displacedState.getViewArea().getLatLonBoundsBox();
+        view = view.intersect(displacedState.getProjection().getWorldBoundsLatLon());
+        if (view == null) {
+            return new TileRange();
+        } else {
+            TileXY t1 = tileSource.latLonToTileXY(view.getMin().toCoordinate(), zoom);
+            TileXY t2 = tileSource.latLonToTileXY(view.getMax().toCoordinate(), zoom);
+            return new TileRange(t1, t2, zoom);
+        }
+    }
+
+    /**
+     * Gets the mathematically best zoom. May be out of range.
+     * @return The zoom
+     */
+    public int getBestZoom() {
+        double factor = getScaleFactor(1); // check the ratio between area of tilesize at zoom 1 to current view
+        double result = Math.log(factor) / Math.log(2) / 2;
+        /*
+         * Math.log(factor)/Math.log(2) - gives log base 2 of factor
+         * We divide result by 2, as factor contains ratio between areas. We could do Math.sqrt before log, or just divide log by 2
+         *
+         * ZOOM_OFFSET controls, whether we work with overzoomed or underzoomed tiles. Positive ZOOM_OFFSET
+         * is for working with underzoomed tiles (higher quality when working with aerial imagery), negative ZOOM_OFFSET
+         * is for working with overzoomed tiles (big, pixelated), which is good when working with high-dpi screens and/or
+         * maps as a imagery layer
+         */
+
+        return (int) Math.round(result + 1 + AbstractTileSourceLayer.ZOOM_OFFSET.get() / 1.9);
+    }
+
+    /**
+     * Gets the clip to use to only paint inside the projection
+     * @return The clip.
+     */
+    public Shape getProjectionClip() {
+        return displacedState.getArea(displacedState.getProjection().getWorldBoundsLatLon());
     }
 }
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/TileForAreaFinder.java b/src/org/openstreetmap/josm/gui/layer/imagery/TileForAreaFinder.java
new file mode 100644
index 0000000..3410390
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/TileForAreaFinder.java
@@ -0,0 +1,114 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.data.Bounds;
+
+/**
+ * This class helps finding loaded tiles for a tile area.
+ * @author Michael Zangl
+ * @since xxx
+ */
+public final class TileForAreaFinder {
+
+    private TileForAreaFinder() {
+        // hidden
+    }
+
+    /**
+     * Get a stream of all tile positions to paint for the given zoom level.
+     * @param initialRange The range
+     * @param rangeProducer An object that converts between {@link Bounds} and {@link TilePosition}
+     * @return A stream of tiles to paint.
+     */
+    public static Stream<TilePosition> getAtDefaultZoom(TileRange initialRange, TileForAreaGetter rangeProducer) {
+        return initialRange.tilePositions().filter(rangeProducer::isAvailable);
+    }
+
+    /**
+     * Gets a stream of all positions to be painted taking the fallback zoom levels into account.
+     * <p>
+     * Limiting the resulting stream won't change the performance of this method. It returns a stream so that this may be changed in the future.
+     *
+     * @param initialRange The range
+     * @param rangeProducer An object that converts between {@link Bounds} and {@link TilePosition}
+     * @param zoom The zoom levels to try at.
+     * @return A stream of tiles to paint.
+     */
+    public static Stream<TilePosition> getWithFallbackZoom(TileRange initialRange, TileForAreaGetter rangeProducer, ZoomLevelManager zoom) {
+        ArrayList<TilePosition> list = new ArrayList<>();
+        List<List<Bounds>> missedInPreviousRuns = new ArrayList<>();
+        List<Bounds> missed = initialRange.tilePositions().flatMap(pos -> addPosition(pos, rangeProducer, list)).collect(Collectors.toList());
+        List<Bounds> missedInLastRun = missed;
+
+        for (int delta : new int[] { -1, 1, -2, 2, -3, -4, -5 }) {
+            int zoomLevel = delta + initialRange.getZoom();
+            if (zoomLevel >= zoom.getMinZoom() && zoomLevel <= zoom.getMaxZoom()) {
+                missed = missedInLastRun
+                    .stream()
+                    .flatMap(b -> rangeProducer.toRangeAtZoom(b, zoomLevel).tilePositions())
+                    .distinct()
+                    .filter(tile -> missedInPreviousRuns.stream().allMatch(l -> l.stream().anyMatch(rangeProducer.getBounds(tile)::intersects)))
+                    .flatMap(pos -> addPosition(pos, rangeProducer, list))
+                    .collect(Collectors.toList());
+                Main.trace("Still missed {0} tile areas at zoom {1}.", missed.size(), zoomLevel);
+                if (missed.isEmpty()) {
+                    break;
+                }
+
+                missedInPreviousRuns.add(missedInLastRun);
+                missedInLastRun = missed;
+            }
+            // no break condition. But missed will be empty, so flatMap should not be costy.
+        }
+
+        Collections.reverse(list);
+        return list.stream().distinct();
+    }
+
+    private static Stream<Bounds> addPosition(TilePosition pos, TileForAreaGetter rangeProducer, ArrayList<TilePosition> addTo) {
+        if (rangeProducer.isAvailable(pos)) {
+            addTo.add(pos);
+            return Stream.empty();
+        } else {
+            return Stream.of(rangeProducer.getBounds(pos));
+        }
+    }
+
+    /**
+     * Classes implementing this interface allow us to convert between a tile range and {@link Bounds}.
+     * @author Michael Zangl
+     * @since xxx
+     */
+    public interface TileForAreaGetter {
+        /**
+         * Gets a tile range that is enclosing this tile at the given zoom level.
+         * @param bounds The bounds to get the range for
+         * @param zoom The zoom the range should be at
+         * @return The range for the given bounds.
+         */
+        public TileRange toRangeAtZoom(Bounds bounds, int zoom);
+
+        /**
+         * Gets the bounds for a tile
+         * @param tile The tile
+         * @return The bounds for that tile
+         */
+        public Bounds getBounds(TilePosition tile);
+
+        /**
+         * Checks if an image is available for the given tile
+         * @param tile The tile to check
+         * @return True if it is available.
+         */
+        public boolean isAvailable(TilePosition tile);
+
+    }
+
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/TilePosition.java b/src/org/openstreetmap/josm/gui/layer/imagery/TilePosition.java
new file mode 100644
index 0000000..c823341
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/TilePosition.java
@@ -0,0 +1,111 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import java.io.Serializable;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.TileXY;
+
+/**
+ * The position of a single tile. In contrast to {@link TileXY}, this stores the position of the whole tile.
+ * @author Michael Zangl
+ * @since xxx
+ */
+public class TilePosition implements Serializable {
+    private static final long serialVersionUID = 1;
+
+    private final int x;
+    private final int y;
+    private final int zoom;
+
+    /**
+     * Create a new tile position object
+     * @param x The x coordinate
+     * @param y The y coordinate
+     * @param zoom The zoom at which the tile is.
+     */
+    TilePosition(int x, int y, int zoom) {
+        super();
+        this.x = x;
+        this.y = y;
+        this.zoom = zoom;
+    }
+
+    /**
+     * Create a new tile position object
+     * @param tile The tile from wich the position should be copied.
+     */
+    public TilePosition(Tile tile) {
+        this(tile.getXtile(), tile.getYtile(), tile.getZoom());
+    }
+
+    /**
+     * @return the x position
+     */
+    public int getX() {
+        return x;
+    }
+
+    /**
+     * @return the y position
+     */
+    public int getY() {
+        return y;
+    }
+
+    /**
+     * @return the zoom
+     */
+    public int getZoom() {
+        return zoom;
+    }
+
+    /**
+     * Gets an x/y coordinate inside this tile
+     * @param du x delta. Range should be 0..1
+     * @param dv y delta. Range should be 0..1
+     * @return The x/y coordinate
+     */
+    public TileXY uv(double du, double dv) {
+        return new TileXY(getX() + du, getY() + dv);
+    }
+
+    /* (non-Javadoc)
+     * @see java.lang.Object#hashCode()
+     */
+    @Override
+    public int hashCode() {
+        final int prime = 31;
+        int result = 1;
+        result = prime * result + x;
+        result = prime * result + y;
+        result = prime * result + zoom;
+        return result;
+    }
+
+    /* (non-Javadoc)
+     * @see java.lang.Object#equals(java.lang.Object)
+     */
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj)
+            return true;
+        if (obj == null)
+            return false;
+        if (getClass() != obj.getClass())
+            return false;
+        TilePosition other = (TilePosition) obj;
+        if (x != other.x)
+            return false;
+        if (y != other.y)
+            return false;
+        if (zoom != other.zoom)
+            return false;
+        return true;
+    }
+
+    @Override
+    public String toString() {
+        return "TilePosition [x=" + x + ", y=" + y + ", zoom=" + zoom + "]";
+    }
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/TileRange.java b/src/org/openstreetmap/josm/gui/layer/imagery/TileRange.java
new file mode 100644
index 0000000..901447d
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/TileRange.java
@@ -0,0 +1,81 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import java.util.Comparator;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.openstreetmap.gui.jmapviewer.TileXY;
+
+/**
+ * This is a rectangular range of tiles.
+ */
+class TileRange {
+    int minX;
+    int maxX;
+    int minY;
+    int maxY;
+    int zoom;
+
+    TileRange() {
+    }
+
+    protected TileRange(TileXY t1, TileXY t2, int zoom) {
+        minX = (int) Math.floor(Math.min(t1.getX(), t2.getX()));
+        minY = (int) Math.floor(Math.min(t1.getY(), t2.getY()));
+        maxX = (int) Math.ceil(Math.max(t1.getX(), t2.getX()));
+        maxY = (int) Math.ceil(Math.max(t1.getY(), t2.getY()));
+        this.zoom = zoom;
+    }
+
+    protected double tilesSpanned() {
+        return Math.sqrt(1.0 * this.size());
+    }
+
+    protected int size() {
+        int xSpan = maxX - minX + 1;
+        int ySpan = maxY - minY + 1;
+        return xSpan * ySpan;
+    }
+
+    /**
+     * @return comparator, that sorts the tiles from the center to the edge of the current screen
+     */
+    private Comparator<TilePosition> getTileDistanceComparator() {
+        final int centerX = (int) Math.ceil((minX + maxX) / 2d);
+        final int centerY = (int) Math.ceil((minY + maxY) / 2d);
+        return Comparator.comparingInt(t -> Math.abs(t.getX() - centerX) + Math.abs(t.getY() - centerY));
+    }
+
+    /**
+     * Gets a stream of all tile positions in this set
+     * @return A stream of all positions
+     */
+    public Stream<TilePosition> tilePositions() {
+        if (zoom == 0) {
+            return Stream.empty();
+        } else {
+            return IntStream.rangeClosed(minX, maxX).mapToObj(
+                    x -> IntStream.rangeClosed(minY, maxY).mapToObj(y -> new TilePosition(x, y, zoom))
+                    ).flatMap(Function.identity());
+        }
+    }
+
+    /**
+     * Gets all tile positions with the ones in the center of the view first.
+     * @return The tile positions
+     * @see #tilePositions()
+     */
+    public Stream<TilePosition> tilePositionsSorted() {
+        return tilePositions().sorted(getTileDistanceComparator());
+    }
+
+    /**
+     * Get the zoom level this range is for
+     * @return The zoom.
+     */
+    public int getZoom() {
+        return zoom;
+    }
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java b/src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java
index 67ab88a..f766ac2 100644
--- a/src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java
@@ -212,7 +212,7 @@ public class TileSourceDisplaySettings {
      * @param changedSetting The setting name
      */
     private void fireSettingsChange(String changedSetting) {
-        DisplaySettingsChangeEvent e = new DisplaySettingsChangeEvent(changedSetting);
+        DisplaySettingsChangeEvent e = new DisplaySettingsChangeEvent(this, changedSetting);
         for (DisplaySettingsChangeListener l : listeners) {
             l.displaySettingsChanged(e);
         }
@@ -332,13 +332,24 @@ public class TileSourceDisplaySettings {
      * @author Michael Zangl
      */
     public static final class DisplaySettingsChangeEvent {
+        private final TileSourceDisplaySettings source;
         private final String changedSetting;
 
-        DisplaySettingsChangeEvent(String changedSetting) {
+        DisplaySettingsChangeEvent(TileSourceDisplaySettings source, String changedSetting) {
+            this.source = source;
             this.changedSetting = changedSetting;
         }
 
         /**
+         * Gets the display settings that caused this event.
+         * @return The settings.
+         * @since xxx
+         */
+        public TileSourceDisplaySettings getSource() {
+            return source;
+        }
+
+        /**
          * Gets the setting that was changed
          * @return The name of the changed setting.
          */
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/TileSourcePainter.java b/src/org/openstreetmap/josm/gui/layer/imagery/TileSourcePainter.java
new file mode 100644
index 0000000..9dd7ad7
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/TileSourcePainter.java
@@ -0,0 +1,563 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.Graphics2D;
+import java.awt.GridBagLayout;
+import java.awt.Image;
+import java.awt.Rectangle;
+import java.awt.Shape;
+import java.awt.Toolkit;
+import java.awt.event.ActionEvent;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.awt.image.ImageObserver;
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map.Entry;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import javax.swing.AbstractAction;
+import javax.swing.Action;
+import javax.swing.BorderFactory;
+import javax.swing.JLabel;
+import javax.swing.JMenuItem;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JSeparator;
+import javax.swing.JTextField;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.TileXY;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource;
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.data.Bounds;
+import org.openstreetmap.josm.data.imagery.TMSCachedTileLoader;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.gui.ExtendedDialog;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.MapViewState.MapViewLatLonRectangle;
+import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
+import org.openstreetmap.josm.gui.MapViewState.MapViewRectangle;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.gui.layer.ImageryLayer;
+import org.openstreetmap.josm.gui.layer.MapViewGraphics;
+import org.openstreetmap.josm.gui.layer.MapViewPaintable.LayerPainter;
+import org.openstreetmap.josm.gui.layer.MapViewPaintable.MapViewEvent;
+import org.openstreetmap.josm.tools.GBC;
+import org.openstreetmap.josm.tools.Pair;
+
+/**
+ * This class is used to paint a {@link AbstractTileSourceLayer} to a given map view.
+ * @author Michael Zangl
+ * @param <T> The imagery type to use
+ * @since xxx
+ */
+public class TileSourcePainter<T extends AbstractTMSTileSource> extends AbstractTileSourceLoader<T> implements LayerPainter {
+    /**
+     *
+     */
+    protected final AbstractTileSourceLayer<T> layer;
+    private static final Font INFO_FONT = new Font("sansserif", Font.BOLD, 13);
+    /**
+     * Absolute maximum of tiles to paint
+     */
+    private static final int MAX_TILES = 500;
+
+    protected final ZoomLevelManager zoom;
+
+    private final TextPainter textPainter;
+
+    private TilePosition highlightPosition;
+
+    final MouseAdapter adapter = new TilePainterMouseAdapter();
+
+    private final MapView mapView;
+
+    /**
+     * Create a new {@link TileSourcePainter}
+     * @param layer The layer to paint
+     * @param mapView The map view to paint for.
+     */
+    public TileSourcePainter(AbstractTileSourceLayer<T> layer, MapView mapView) {
+        super(layer);
+        this.layer = layer;
+        this.mapView = mapView;
+        mapView.addMouseListener(adapter);
+
+        textPainter = new TextPainter();
+        zoom = new ZoomLevelManager(getSettings(), tileSource, generateCoordinateConverter());
+        zoom.setZoomBounds(layer.getInfo());
+    }
+
+    @Override
+    public void paint(MapViewGraphics graphics) {
+        boolean hasAllocated = allocateCacheMemory();
+
+        textPainter.start(graphics.getDefaultGraphics());
+
+        if (hasAllocated) {
+            doPaint(graphics);
+        } else {
+            textPainter.addTextOverlay(tr("There is noth enough memory to display this layer."));
+        }
+    }
+
+    private void doPaint(MapViewGraphics graphics) {
+        MapViewRectangle pb = graphics.getClipBounds();
+
+        drawInViewArea(graphics.getDefaultGraphics(), graphics.getMapView(), pb);
+    }
+
+    private void drawInViewArea(Graphics2D g, MapView mapView, MapViewRectangle rect) {
+        g.setFont(INFO_FONT);
+        TileCoordinateConverter converter = generateCoordinateConverter();
+        zoom.updateZoomLevel(converter, this);
+        loadTilesInView(converter);
+
+        TileRange baseRange = converter.getViewAtZoom(zoom.getCurrentZoomLevel());
+
+        Shape clip = g.getClip();
+        g.setClip(converter.getProjectionClip());
+        Stream<TilePosition> area;
+        if (getSettings().isAutoZoom()) {
+            area = TileForAreaFinder.getWithFallbackZoom(baseRange, this, zoom);
+        } else {
+            area = TileForAreaFinder.getAtDefaultZoom(baseRange, this);
+        }
+        paintTileImages(g, area);
+        g.setClip(clip);
+
+        if (highlightPosition != null) {
+            paintHighlight(g, converter, highlightPosition);
+        }
+        paintStatus(baseRange, mapView.getProjection());
+        paintAttribution(g, rect);
+        if (Main.isDebugEnabled()) {
+            paintDebug();
+        }
+    }
+
+    /**
+     * Paints a highlight rectangle around a tile.
+     * @param g
+     * @param converter
+     * @param tile
+     */
+    private static void paintHighlight(Graphics2D g, TileCoordinateConverter converter, TilePosition tile) {
+        MapViewLatLonRectangle area = converter.getAreaForTile(tile);
+        g.setColor(Color.RED);
+        g.draw(area.getInView());
+    }
+
+    /**
+     * Paint the filtered images for the given tiles
+     * @param g The graphics to paint on
+     * @param area The tiles to paint.
+     */
+    private void paintTileImages(Graphics2D g, Stream<TilePosition> area) {
+        TileCoordinateConverter converter = generateCoordinateConverter();
+        Rectangle b = g.getClipBounds();
+        int maxTiles = (int) (b.getWidth() * b.getHeight() / tileSource.getTileSize() / tileSource.getTileSize() * 5);
+        List<Tile> errorTiles = Collections.synchronizedList(new ArrayList<>());
+        Stream<Tile> tiles = area.parallel()
+            .limit(Math.min(maxTiles, MAX_TILES))
+            .map(this::getTile)
+            .filter(Objects::nonNull);
+
+        if (getSettings().isShowErrors()) {
+            tiles = tiles.peek(t -> { if (t.hasError()) errorTiles.add(t); });
+        }
+        tiles.map(tile -> new Pair<>(tile, tile.getImage()))
+            .filter(p -> imageLoaded(p.b))
+            .map(p -> new Pair<>(p.a, layer.applyImageProcessors(p.b)))
+            .forEachOrdered(p -> paintTileImage(g, p.a, p.b, converter));
+
+        for (Tile error : errorTiles) {
+            textPainter.drawTileString(tr("Error") + ": " + tr(error.getErrorMessage()),
+                    new TilePosition(error), converter);
+        }
+    }
+
+    /**
+     * We only paint full tile images.
+     * <p>
+     * We handle that the correct tile images are in front by sorting the list of tiles accordingly.
+     * @param g The graphics to paint on
+     * @param tile The tile to paint
+     * @param image The image to paint for the tile
+     * @param converter The coordinate converter.
+     */
+    private void paintTileImage(Graphics2D g, Tile tile, BufferedImage image, TileCoordinateConverter converter) {
+        AffineTransform transform = converter.getTransformForTile(new TilePosition(tile), 0, 0, 0, 1, 1, 1);
+        transform.scale(1.0 / image.getWidth(), 1.0 / image.getHeight());
+
+        g.drawImage(image, transform, layer);
+
+        if (ImageryLayer.PROP_FADE_AMOUNT.get() != 0) {
+            // dimm by painting opaque rect...
+            // TODO: Convert this to a filter.
+            g.setColor(ImageryLayer.getFadeColorWithAlpha());
+            AffineTransform oldTrans = g.getTransform();
+            g.transform(transform);
+            g.fillRect(0, 0, image.getWidth(), image.getHeight());
+            g.setTransform(oldTrans);
+        }
+
+        if (Main.isTraceEnabled()) {
+            textPainter.drawTileString(tile.getKey(), new TilePosition(tile), converter);
+        }
+    }
+
+    private void paintAttribution(Graphics2D defaultGraphics, MapViewRectangle rect) {
+        Rectangle2D inView = rect.getInView();
+        Graphics2D g = (Graphics2D) defaultGraphics.create();
+        g.translate(inView.getMinX(), inView.getMinY());
+        Bounds boundsBox = rect.getLatLonBoundsBox();
+        attribution.paintAttribution(g, (int) inView.getWidth(), (int) inView.getHeight(),
+                boundsBox.getMin().toCoordinate(), boundsBox.getMax().toCoordinate(), zoom.getDisplayZoomLevel(),
+                layer);
+    }
+
+    private void paintStatus(TileRange baseRange, Projection projection) {
+        if (isTooLarge(baseRange)) {
+            textPainter.addTextOverlay(tr("zoom in to load more tiles"));
+        } else if (!getSettings().isAutoZoom() && isTooSmall(baseRange)) {
+            textPainter.addTextOverlay(tr("increase tiles zoom level (change resolution) to see more detail"));
+        } else if (getSettings().isAutoZoom() && getSettings().isAutoLoad() && !hasTiles(baseRange, TileSourcePainter::isVisible)
+                && (!hasTiles(baseRange, TileSourcePainter::isLoading) || hasTiles(baseRange, TileSourcePainter::isOverzoomed))) {
+            textPainter.addTextOverlay(tr("No tiles at this zoom level"));
+        }
+
+        if (!isProjectionSupported(projection)) {
+            textPainter.addTextOverlay(tr("The tile source does not support the current projection natively"));
+        }
+    }
+
+    private void paintDebug() {
+        for (String s : zoom.getDebugInfo(generateCoordinateConverter())) {
+            textPainter.addDebug(s);
+        }
+        textPainter.addDebug(tr("Estimated cache size: {0}", estimateTileCacheSize()));
+        if (tileLoader instanceof TMSCachedTileLoader) {
+            TMSCachedTileLoader cachedTileLoader = (TMSCachedTileLoader) tileLoader;
+            for (String part : cachedTileLoader.getStats().split("\n")) {
+                textPainter.addDebug(tr("Cache stats: {0}", part));
+            }
+        }
+    }
+
+    private void loadTilesInView(TileCoordinateConverter converter) {
+        int zoomToLoad = zoom.getDisplayZoomLevel();
+        TileRange range = converter.getViewAtZoom(zoomToLoad);
+
+        if (getSettings().isAutoZoom()) {
+        // If all tiles at displayZoomLevel is loaded, load all tiles at next zoom level
+        // to make sure there're really no more zoom levels
+        if (zoomToLoad < zoom.getCurrentZoomLevel() && !hasTiles(range, TileSourcePainter::isMissing)) {
+            zoomToLoad++;
+            range = converter.getViewAtZoom(zoomToLoad);
+        } else  {
+            // When we have overzoomed tiles and all tiles at current zoomlevel is loaded,
+            // load tiles at previovus zoomlevels until we have all tiles on screen is loaded.
+            // loading is done in the next if section
+            while (zoomToLoad > zoom.getMinZoom() && hasTiles(range, TileSourcePainter::isOverzoomed)
+                    && !hasTiles(range, TileSourcePainter::isMissing)) {
+                zoomToLoad--;
+                range = converter.getViewAtZoom(zoomToLoad);
+            }
+        }
+        }
+        loadTiles(range, false);
+    }
+
+    private void loadErrorTiles(TileRange range, boolean force) {
+        if (getSettings().isAutoLoad() || force) {
+            range.tilePositionsSorted().map(this::getOrCreateTile).filter(Tile::hasError)
+                    .forEach(t -> tileLoader.createTileLoaderJob(t).submit(force));
+        }
+    }
+
+    protected void loadAllErrorTiles(boolean force) {
+        loadErrorTiles(generateCoordinateConverter().getViewAtZoom(zoom.getCurrentZoomLevel()), force);
+    }
+
+    protected void loadAllTiles(boolean force) {
+        loadTiles(generateCoordinateConverter().getViewAtZoom(zoom.getCurrentZoomLevel()), force);
+    }
+
+    @Override
+    protected void loadTiles(TileRange range, boolean force) {
+        super.loadTiles(range, force || getSettings().isAutoLoad());
+    }
+
+    private boolean imageLoaded(Image i) {
+        if (i == null) {
+            return false;
+        } else {
+            int status = Toolkit.getDefaultToolkit().checkImage(i, -1, -1, layer);
+            return (status & ImageObserver.ALLBITS) != 0;
+        }
+    }
+
+    private TileCoordinateConverter generateCoordinateConverter() {
+        return new TileCoordinateConverter(mapView.getState(), tileSource, getSettings());
+    }
+
+    /**
+     * Check whether this layer supports the given projection
+     * @param projection The projection to search
+     * @return <code>true</code> if supported.
+     */
+    protected boolean isProjectionSupported(Projection projection) {
+        return true;
+    }
+
+    private TileSourceDisplaySettings getSettings() {
+        return layer.getDisplaySettings();
+    }
+
+    /**
+     * Gets the menu entries for this layer
+     * @return The menu entries
+     */
+    public List<Action> getMenuEntries() {
+        return Arrays.asList(zoom.new IncreaseZoomAction(), zoom.new DecreaseZoomAction(),
+                zoom.new ZoomToBestAction(mapView), zoom.new ZoomToNativeLevelAction(mapView),
+                new FlushTileCacheAction(), new LoadErroneusTilesAction(), new LoadAllTilesAction());
+    }
+
+    /**
+     * Gets the current zoom level as String
+     * @return The zoom level.
+     */
+    public String getZoomString() {
+        return Integer.toString(zoom.getCurrentZoomLevel());
+    }
+
+    @Override
+    public void detachFromMapView(MapViewEvent event) {
+        event.getMapView().removeMouseListener(adapter);
+        MapView.removeZoomChangeListener(this);
+        freeCacheMemory();
+        layer.detach(this);
+    }
+
+    private final class TilePainterMouseAdapter extends MouseAdapter {
+        @Override
+        public void mouseClicked(MouseEvent e) {
+            if (e.getButton() == MouseEvent.BUTTON3) {
+                TilePosition tilePos = getTileForPixelpos(mapView.getState().getForView(e.getPoint()));
+                JPopupMenu popup = layer.new TileSourceLayerPopup(mapView);
+                popup.addPopupMenuListener(new PopupMenuListener() {
+                    @Override
+                    public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
+                        highlightPosition = tilePos;
+                        // triggers repaint
+                        layer.invalidate();
+                    }
+
+                    @Override
+                    public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
+                        highlightPosition = null;
+                        layer.invalidate();
+                    }
+
+                    @Override
+                    public void popupMenuCanceled(PopupMenuEvent e) {
+                        // ignore
+                    }
+                });
+                if (tilePos != null) {
+                    popup.add(new JSeparator());
+                    popup.add(new JMenuItem(new LoadTileAction(tilePos)));
+                    Tile tile = getOrCreateTile(tilePos);
+                    if (tile != null) {
+                        popup.add(new JMenuItem(new ShowTileInfoAction(tile)));
+                    }
+                }
+                popup.show(e.getComponent(), e.getX(), e.getY());
+            } else if (e.getButton() == MouseEvent.BUTTON1) {
+                attribution.handleAttribution(e.getPoint(), true);
+            }
+        }
+
+        /**
+         * Returns tile for a pixel position.<p>
+         * This isn't very efficient, but it is only used when the user right-clicks on the map.
+         * @param mapViewPoint pixel coordinate
+         * @return Tile at pixel position
+         */
+        private TilePosition getTileForPixelpos(MapViewPoint mapViewPoint) {
+            Main.trace("getTileForPixelpos({0})", mapViewPoint);
+            TileCoordinateConverter converter = generateCoordinateConverter();
+
+            TileRange ts = converter.getViewAtZoom(zoom.getCurrentZoomLevel());
+
+            Stream<TilePosition> clickedTiles = ts.tilePositions()
+                    .filter(t -> converter.getAreaForTile(t).contains(mapViewPoint));
+            if (Main.isTraceEnabled()) {
+                clickedTiles = clickedTiles.peek(t -> Main.trace("Clicked on tile: {0}, {1};  currentZoomLevel: {2}",
+                        t.getX(), t.getY(), zoom.getCurrentZoomLevel()));
+            }
+            return clickedTiles.findAny().orElse(null);
+        }
+    }
+
+    private class LoadAllTilesAction extends AbstractAction {
+        LoadAllTilesAction() {
+            super(tr("Load all tiles"));
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent ae) {
+            loadAllTiles(true);
+        }
+    }
+
+    private class LoadErroneusTilesAction extends AbstractAction {
+        LoadErroneusTilesAction() {
+            super(tr("Load all error tiles"));
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent ae) {
+            loadAllErrorTiles(true);
+        }
+    }
+
+    private final class ShowTileInfoAction extends AbstractAction {
+
+        private final transient Tile clickedTile;
+
+        private ShowTileInfoAction(Tile clickedTile) {
+            super(tr("Show tile info"));
+            this.clickedTile = clickedTile;
+        }
+
+        private String getSizeString(int size) {
+            StringBuilder ret = new StringBuilder();
+            return ret.append(size).append('x').append(size).toString();
+        }
+
+        private JTextField createTextField(String text) {
+            JTextField ret = new JTextField(text);
+            ret.setEditable(false);
+            ret.setBorder(BorderFactory.createEmptyBorder());
+            return ret;
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent ae) {
+            ExtendedDialog ed = new ExtendedDialog(Main.parent, tr("Tile Info"), new String[] { tr("OK") });
+            JPanel panel = new JPanel(new GridBagLayout());
+            MapViewLatLonRectangle displaySize = generateCoordinateConverter().getAreaForTile(new TilePosition(clickedTile));
+            Rectangle2D bounds = displaySize.getInView().getBounds2D();
+            String[][] content = { { "Tile name", clickedTile.getKey() }, { "Tile url", getUrl() },
+                    { "Tile size", getSizeString(clickedTile.getTileSource().getTileSize()) },
+                    { "Position in view", MessageFormat.format("x={0}..{1}, y={2}..{3}",
+                            bounds.getMinX(), bounds.getMaxX(),
+                            bounds.getMinY(), bounds.getMaxY())},
+                    { "Position on projection",MessageFormat.format("east={0}..{1}, north={2}..{3}",
+                            displaySize.getProjectionBounds().minEast, displaySize.getProjectionBounds().maxEast,
+                            displaySize.getProjectionBounds().minNorth, displaySize.getProjectionBounds().maxNorth)},
+                    { "Position on world",MessageFormat.format("lat={0}..{1}, lon={2}..{3}",
+                            displaySize.getLatLonBoundsBox().getMinLat(), displaySize.getLatLonBoundsBox().getMaxLat(),
+                            displaySize.getLatLonBoundsBox().getMinLon(), displaySize.getLatLonBoundsBox().getMaxLon())},
+            };
+
+            for (String[] entry : content) {
+                panel.add(new JLabel(tr(entry[0]) + ':'), GBC.std());
+                panel.add(GBC.glue(5, 0), GBC.std());
+                panel.add(createTextField(entry[1]), GBC.eol().fill(GBC.HORIZONTAL));
+            }
+
+            for (Entry<String, String> e : clickedTile.getMetadata().entrySet()) {
+                panel.add(new JLabel(tr("Metadata ") + tr(e.getKey()) + ':'), GBC.std());
+                panel.add(GBC.glue(5, 0), GBC.std());
+                String value = e.getValue();
+                if ("lastModification".equals(e.getKey()) || "expirationTime".equals(e.getKey())) {
+                    value = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date(Long.parseLong(value)));
+                }
+                panel.add(createTextField(value), GBC.eol().fill(GBC.HORIZONTAL));
+
+            }
+            ed.setIcon(JOptionPane.INFORMATION_MESSAGE);
+            ed.setContent(panel);
+            ed.showDialog();
+        }
+
+        private String getUrl() {
+            try {
+                return clickedTile.getUrl();
+            } catch (IOException e) {
+                // silence exceptions
+                Main.trace(e);
+                return "";
+            }
+        }
+    }
+
+    private final class LoadTileAction extends AbstractAction {
+
+        private final transient TilePosition clickedTile;
+
+        private LoadTileAction(TilePosition clickedTile) {
+            super(tr("Load tile"));
+            this.clickedTile = clickedTile;
+            setEnabled(clickedTile != null);
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent ae) {
+            loadTile(clickedTile, true);
+            layer.invalidate();
+        }
+    }
+
+    @Override
+    public Bounds getBounds(TilePosition tilePos) {
+        ICoordinate min = tileSource.tileXYToLatLon(tilePos.getX(), tilePos.getY(), tilePos.getZoom());
+        ICoordinate max = tileSource.tileXYToLatLon(tilePos.getX() + 1, tilePos.getY() + 1, tilePos.getZoom());
+        Bounds bounds = new Bounds(min.getLat(), min.getLon(), false);
+        bounds.extend(max.getLat(), max.getLon());
+        return bounds;
+    }
+
+    @Override
+    public TileRange toRangeAtZoom(Bounds bounds, int zoom) {
+        TileXY t1 = tileSource.latLonToTileXY(bounds.getMinLat(), bounds.getMinLon(), zoom);
+        TileXY t2 = tileSource.latLonToTileXY(bounds.getMaxLat(), bounds.getMaxLon(), zoom);
+        return new TileRange(t1, t2, zoom);
+    }
+
+    @Override
+    public boolean isAvailable(TilePosition tilePos) {
+        Tile tile = getTile(tilePos);
+        return tile != null && !tile.hasError()
+                && !(isOverzoomed(tile) && tilePos.getZoom() > zoom.getDisplayZoomLevel())
+                && isImageAvailable(tile);
+    }
+
+    private boolean isImageAvailable(Tile tile) {
+        BufferedImage image = tile.getImage();
+        return imageLoaded(image);
+    }
+}
diff --git a/src/org/openstreetmap/josm/gui/layer/imagery/ZoomLevelManager.java b/src/org/openstreetmap/josm/gui/layer/imagery/ZoomLevelManager.java
new file mode 100644
index 0000000..fe0fea4
--- /dev/null
+++ b/src/org/openstreetmap/josm/gui/layer/imagery/ZoomLevelManager.java
@@ -0,0 +1,297 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.swing.AbstractAction;
+
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+
+/**
+ * This class manages the zoom level of a {@link TileSourcePainter}
+ * @author Michael Zangl
+ * @since xxx
+ */
+public class ZoomLevelManager {
+    /**
+     * Zoomlevel selected by the user.
+     */
+    private int currentZoomLevel;
+    /**
+     * The zoom level at which tiles are currently displayed
+     */
+    private int displayZoomLevel;
+    private TileSourceDisplaySettings settings;
+    private TileSource source;
+
+    private int minZoom;
+    private int maxZoom;
+
+    /**
+     * Create a new zoom level manager
+     * @param settings The zoom settings
+     * @param source The tile source to use.
+     * @param initialZoomState The initial state to compute the zoom factor from.
+     */
+    public ZoomLevelManager(TileSourceDisplaySettings settings, TileSource source, TileCoordinateConverter initialZoomState) {
+        this.settings = settings;
+        this.source = source;
+
+        setZoomLevel(clampZoom(initialZoomState.getBestZoom()));
+        setZoomBounds(source.getMinZoom(), source.getMaxZoom());
+    }
+
+    /**
+     * Set the zoom bounds
+     * @param bounds An info to get the zoom bounds from
+     */
+    public void setZoomBounds(ImageryInfo bounds) {
+        setZoomBounds(bounds.getMinZoom(), bounds.getMaxZoom());
+    }
+
+    /**
+     * Sets the zoom bounds
+     * @param minZoom The minimum zoom
+     * @param maxZoom The maximum zoom.
+     */
+    public void setZoomBounds(int minZoom, int maxZoom) {
+        if (minZoom > maxZoom || minZoom < 0) {
+            throw new IllegalArgumentException(MessageFormat.format("Zoom range not valid: {0}..{1}", minZoom, maxZoom));
+        }
+        this.minZoom = AbstractTileSourceLayer.checkMinZoomLvl(minZoom, source);
+        this.maxZoom = AbstractTileSourceLayer.checkMaxZoomLvl(maxZoom, source);
+    }
+
+    /**
+     * @return The min zoom that was set.
+     */
+    public int getMinZoom() {
+        return minZoom;
+    }
+
+    /**
+     * @return The max zoom that was set.
+     */
+    public int getMaxZoom() {
+        return maxZoom;
+    }
+
+    /**
+     *
+     * @return if its allowed to zoom in
+     */
+    public boolean zoomIncreaseAllowed() {
+        boolean zia = currentZoomLevel < this.getMaxZoom();
+        if (Main.isDebugEnabled()) {
+            Main.debug("zoomIncreaseAllowed(): " + zia + ' ' + currentZoomLevel + " vs. " + this.getMaxZoom());
+        }
+        return zia;
+    }
+
+    /**
+     * Zoom in, go closer to map.
+     *
+     * @return    true, if zoom increasing was successful, false otherwise
+     */
+    public boolean increaseZoomLevel() {
+        return setZoomLevel(currentZoomLevel + 1);
+    }
+
+    /**
+     * Sets the zoom level of the layer
+     * @param zoom zoom level
+     * @return true, when zoom has changed to desired value, false if it was outside supported zoom levels
+     */
+    public boolean setZoomLevel(int zoom) {
+        if (zoom == currentZoomLevel && zoom == displayZoomLevel) {
+            return true;
+        } else if (zoom < getMinZoom() || zoom > getMaxZoom()) {
+            Main.warn("Current zoom level ({0}) could not be changed to {1}: out of range {2} .. {3}", currentZoomLevel,
+                    zoom, getMinZoom(), getMaxZoom());
+            return false;
+        } else {
+            Main.debug("changing zoom level to: {0}", currentZoomLevel);
+            currentZoomLevel = zoom;
+            displayZoomLevel = zoom;
+            return true;
+        }
+    }
+
+    /**
+     * Check if zooming out is allowed
+     *
+     * @return    true, if zooming out is allowed (currentZoomLevel &gt; minZoomLevel)
+     */
+    public boolean zoomDecreaseAllowed() {
+        boolean zda = currentZoomLevel > this.getMinZoom();
+        if (Main.isDebugEnabled()) {
+            Main.debug("zoomDecreaseAllowed(): " + zda + ' ' + currentZoomLevel + " vs. " + this.getMinZoom());
+        }
+        return zda;
+    }
+
+    /**
+     * Zoom out from map.
+     *
+     * @return    true, if zoom increasing was successfull, false othervise
+     */
+    public boolean decreaseZoomLevel() {
+        return setZoomLevel(currentZoomLevel - 1);
+    }
+
+    /**
+     * Update the zoom level that should be displayed
+     * @param currentZoomState The coordinate converter that holds the current view
+     * @param viewStatus An accessor for finding out if the given tiles are available.
+     */
+    public void updateZoomLevel(TileCoordinateConverter currentZoomState, TileSourcePainter<?> viewStatus) {
+        if (settings.isAutoZoom()) {
+            int zoom = clampZoom(currentZoomState.getBestZoom());
+            setZoomLevel(zoom);
+            if (settings.isAutoLoad()) {
+                // Find highest zoom level with at least one visible tile
+
+                for (int tmpZoom = zoom; tmpZoom >= getMinZoom(); tmpZoom--) {
+                    TileRange area = currentZoomState.getViewAtZoom(zoom);
+                    if (viewStatus.hasTiles(area, ZoomLevelManager::visibleOrOverzoomed)) {
+                        displayZoomLevel = tmpZoom;
+                        break;
+                    }
+                }
+            }
+        } else {
+            displayZoomLevel = currentZoomLevel;
+        }
+    }
+
+    private static boolean visibleOrOverzoomed(Tile t) {
+        return TileSourcePainter.isVisible(t) || TileSourcePainter.isOverzoomed(t);
+    }
+
+    private int clampZoom(int intResult) {
+        return Math.max(Math.min(intResult, getMaxZoom()), getMinZoom());
+    }
+
+    /**
+     * Gets the current zoom level that is requested by the user for displaying the tiles.
+     * @return The current zoom level of the view
+     */
+    public int getCurrentZoomLevel() {
+        return currentZoomLevel;
+    }
+
+    /**
+     * Gets the zoom level that is suggested to be displayed. This may be different depending on the tile loading settings.
+     * @return The suggested zoom.
+     */
+    public int getDisplayZoomLevel() {
+        return displayZoomLevel;
+    }
+
+    /**
+     * Gets the debug information that should be added about the zoom level.
+     * @param currentZoomState A coordinate converter
+     * @return The current zoom status.
+     */
+    public List<String> getDebugInfo(TileCoordinateConverter currentZoomState) {
+        int bestZoom = currentZoomState.getBestZoom();
+        return Arrays.asList(
+                tr("Current zoom: {0}", getCurrentZoomLevel()),
+                tr("Display zoom: {0}", displayZoomLevel),
+                tr("Pixel scale: {0}", currentZoomState.getScaleFactor(getCurrentZoomLevel())),
+                tr("Best zoom: {0} (clamped to: {1})", bestZoom, clampZoom(bestZoom))
+                );
+    }
+
+    /**
+     * Zooms to the native level of the current view
+     */
+    public class ZoomToNativeLevelAction extends AbstractAction {
+        private final MapView forView;
+
+        /**
+         * Create a new {@link ZoomToNativeLevelAction}
+         * @param forView The map view to zoom
+         */
+        public ZoomToNativeLevelAction(MapView forView) {
+            super(tr("Zoom to native resolution"));
+            this.forView = forView;
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent ae) {
+            TileCoordinateConverter converter = new TileCoordinateConverter(forView.getState(), source, settings);
+            double newFactor = Math.sqrt(converter.getScaleFactor(currentZoomLevel));
+            forView.zoomToFactor(newFactor);
+        }
+    }
+
+    /**
+     * Zooms the layer to the best display zoom for the current map view state
+     */
+    public class ZoomToBestAction extends AbstractAction {
+        private final int bestZoom;
+
+        /**
+         * Create a new {@link ZoomToBestAction}
+         * @param forView The view to use as reference.
+         */
+        public ZoomToBestAction(MapView forView) {
+            super(tr("Change resolution"));
+            bestZoom = clampZoom(new TileCoordinateConverter(forView.getState(), source, settings).getBestZoom());
+            setEnabled(!settings.isAutoZoom() && bestZoom != currentZoomLevel);
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent ae) {
+            setZoomLevel(bestZoom);
+        }
+    }
+
+    /**
+     * Increase the zoom by 1.
+     */
+    public class IncreaseZoomAction extends AbstractAction {
+        /**
+         * Create a new {@link IncreaseZoomAction}
+         */
+        public IncreaseZoomAction() {
+            super(tr("Increase zoom"));
+            setEnabled(!settings.isAutoZoom() && zoomIncreaseAllowed());
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent ae) {
+            increaseZoomLevel();
+        }
+    }
+
+    /**
+     * Decrease the zoom by 1.
+     */
+    public class DecreaseZoomAction extends AbstractAction {
+        /**
+         * Create a new {@link DecreaseZoomAction}
+         */
+        public DecreaseZoomAction() {
+            super(tr("Decrease zoom"));
+            setEnabled(!settings.isAutoZoom() && zoomDecreaseAllowed());
+        }
+
+        @Override
+        public void actionPerformed(ActionEvent ae) {
+            decreaseZoomLevel();
+        }
+    }
+
+}
diff --git a/test/unit/org/openstreetmap/josm/gui/layer/imagery/TileCoordinateConverterTest.java b/test/unit/org/openstreetmap/josm/gui/layer/imagery/TileCoordinateConverterTest.java
new file mode 100644
index 0000000..b673d61
--- /dev/null
+++ b/test/unit/org/openstreetmap/josm/gui/layer/imagery/TileCoordinateConverterTest.java
@@ -0,0 +1,289 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer.imagery;
+
+import static org.junit.Assert.assertEquals;
+
+import java.awt.Image;
+import java.awt.Point;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Point2D;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.openstreetmap.gui.jmapviewer.Coordinate;
+import org.openstreetmap.gui.jmapviewer.Tile;
+import org.openstreetmap.gui.jmapviewer.TileXY;
+import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
+import org.openstreetmap.gui.jmapviewer.interfaces.TileSource;
+import org.openstreetmap.josm.JOSMFixture;
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.gui.MapFrame;
+import org.openstreetmap.josm.gui.MapViewState;
+import org.openstreetmap.josm.gui.MapViewState.MapViewLatLonRectangle;
+import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
+import org.openstreetmap.josm.gui.layer.LayerManagerTest;
+import org.openstreetmap.josm.gui.util.GuiHelper;
+
+/**
+ * Test {@link TileCoordinateConverter}
+ * @author Michael Zangl
+ * @since xxx
+ */
+public class TileCoordinateConverterTest {
+    private static final class TransformingConverter extends TileCoordinateConverter {
+        AffineTransform transform = new AffineTransform();
+
+        private TransformingConverter(MapViewState mapView, TileSource tileSource, TileSourceDisplaySettings settings) {
+            super(mapView, tileSource, settings);
+        }
+
+        @Override
+        protected MapViewPoint pos(ICoordinate ll) {
+            Point2D transformed = transform.transform(new Point2D.Double(ll.getLat(), ll.getLon()), null);
+            return super.pos(new Coordinate(transformed.getX(), transformed.getY()));
+        }
+    }
+
+    private static final class TestTileSource implements TileSource {
+        @Override
+        public boolean requiresAttribution() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getTermsOfUseURL() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getTermsOfUseText() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getAttributionText(int zoom, ICoordinate topLeft, ICoordinate botRight) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getAttributionLinkURL() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getAttributionImageURL() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Image getAttributionImage() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Point latLonToXY(ICoordinate point, int zoom) {
+            return latLonToXY(point.getLat(), point.getLon(), zoom);
+        }
+
+        @Override
+        public ICoordinate xyToLatLon(Point point, int zoom) {
+            return xyToLatLon(point.x, point.y, zoom);
+        }
+
+        @Override
+        public TileXY latLonToTileXY(ICoordinate point, int zoom) {
+            return latLonToTileXY(point.getLat(), point.getLon(), zoom);
+        }
+
+        @Override
+        public ICoordinate tileXYToLatLon(TileXY xy, int zoom) {
+            return tileXYToLatLon(xy.getXIndex(), xy.getYIndex(), zoom);
+        }
+
+        @Override
+        public ICoordinate tileXYToLatLon(Tile tile) {
+            return tileXYToLatLon(tile.getXtile(), tile.getYtile(), tile.getZoom());
+        }
+
+        @Override
+        public boolean isNoTileAtZoom(Map<String, List<String>> headers, int statusCode, byte[] content) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getTileYMin(int zoom) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getTileYMax(int zoom) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getTileXMin(int zoom) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getTileXMax(int zoom) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getTileUrl(int zoom, int tilex, int tiley) throws IOException {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getTileSize() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getTileId(int zoom, int tilex, int tiley) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getName() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getMinZoom() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Map<String, String> getMetadata(Map<String, List<String>> headers) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getMaxZoom() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public String getId() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public double getDistance(double la1, double lo1, double la2, double lo2) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public int getDefaultTileSize() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public Point latLonToXY(double lat, double lon, int zoom) {
+            return new Point((int) lat / 13, (int) lon - 4);
+        }
+
+        @Override
+        public ICoordinate xyToLatLon(int x, int y, int zoom) {
+            return new Coordinate(x * 13, y + 4);
+        }
+
+        @Override
+        public TileXY latLonToTileXY(double lat, double lon, int zoom) {
+            return new TileXY(lat / 13, lon - 4);
+        }
+
+        @Override
+        public ICoordinate tileXYToLatLon(int x, int y, int zoom) {
+            return new Coordinate(x * 13, y + 4);
+        }
+    }
+
+    private TransformingConverter converter;
+
+    /**
+     * Setup test.
+     */
+    @BeforeClass
+    public static void setUpBeforeClass() {
+        JOSMFixture.createUnitTestFixture().init(true);
+        Main.getLayerManager().addLayer(new LayerManagerTest.TestLayer());
+        GuiHelper.runInEDTAndWait(() -> {});
+    }
+
+    /**
+     * Sets up a fake tile source.
+     */
+    @Before
+    public void setUp() {
+        MapFrame map = Main.map;
+        map.mapView.zoomTo(new EastNorth(0, 0), 1);
+        converter = new TransformingConverter(map.mapView.getState(), new TestTileSource(), new TileSourceDisplaySettings());
+    }
+
+    /**
+     * Test {@link TileCoordinateConverter#getAreaForTile(TilePosition)}
+     */
+    @Test
+    public void testGetAreaForTile() {
+        EastNorth p1 = Main.getProjection().latlon2eastNorth(new LatLon(26, 7));
+        MapViewLatLonRectangle rect = converter.getAreaForTile(new TilePosition(2, 3, 1));
+        assertEquals(p1.getX(), rect.getProjectionBounds().minEast, 1e-10);
+        assertEquals(p1.getY(), rect.getProjectionBounds().minNorth, 1e-10);
+    }
+
+    /**
+     * Test {@link TileCoordinateConverter#getTransformForTile(TilePosition, double, double, double, double, double, double)}
+     */
+    @Test
+    public void testGetTransformForTile() {
+        TilePosition tile = new TilePosition(2, 3, 1);
+        Point2D p1 = Main.map.mapView.getState().getPointFor(new LatLon(26, 7)).getInView();
+        Point2D p2 = Main.map.mapView.getState().getPointFor(new LatLon(39, 8)).getInView();
+
+        for (AffineTransform transform : new AffineTransform[] {
+                converter.getTransformForTile(tile, 0, 0, 0, 1, 1, 1),
+                converter.getTransformForTile(tile, 0, 0, 1, 0, 1, 1),
+                converter.getTransformForTile(tile, 0, 0, 1, 1, 1, 0),
+                converter.getTransformForTile(tile, 0, 0, 0, 1, 1, 0),
+        }) {
+            assertEquals(p1.getX(), transform.getTranslateX(), 1e-10);
+            assertEquals(p1.getY(), transform.getTranslateY(), 1e-10);
+
+            Point2D p1converted = transform.transform(new Point2D.Double(0, 0), null);
+            Point2D p2converted = transform.transform(new Point2D.Double(1, 1), null);
+
+            assertEquals(p1.getX(), p1converted.getX(), 1e-10);
+            assertEquals(p1.getY(), p1converted.getY(), 1e-10);
+            assertEquals(p2.getX(), p2converted.getX(), 1e-10);
+            assertEquals(p2.getY(), p2converted.getY(), 1e-10);
+        }
+    }
+        /**
+         * Test {@link TileCoordinateConverter#getTransformForTile(TilePosition, double, double, double, double, double, double)}
+         * ignores unsolveable
+         */
+        @Test
+        public void testGetTransformForTileIgnoresUnsolveable() {
+            TilePosition tile = new TilePosition(2, 3, 1);
+        for (AffineTransform transform : new AffineTransform[] {
+                converter.getTransformForTile(tile, 0, 0, 0, 0, 1, 1),
+                converter.getTransformForTile(tile, 0, 0, 1, 0, 0.5, 0),
+                converter.getTransformForTile(tile, 0, 0, 1, 1, 0, 0),
+                converter.getTransformForTile(tile, 0, 0, 0, 0e-30, 1, 0),
+        }) {
+            assertEquals(1, transform.getScaleX(), 1e-10);
+            assertEquals(1, transform.getScaleY(), 1e-10);
+            assertEquals(0, transform.getTranslateX(), 1e-10);
+            assertEquals(0, transform.getTranslateY(), 1e-10);
+        }
+    }
+}
