Index: trunk/src/org/openstreetmap/josm/actions/AlignGridRotationAction.java
===================================================================
--- trunk/src/org/openstreetmap/josm/actions/AlignGridRotationAction.java	(revision 19640)
+++ trunk/src/org/openstreetmap/josm/actions/AlignGridRotationAction.java	(revision 19640)
@@ -0,0 +1,109 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.actions;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.util.Collection;
+import java.util.Iterator;
+
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.Way;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
+
+/**
+ * Rotates the grid drawn over the map so that its lines run parallel to the current selection: either a single way
+ * (the direction from its first to its last node) or exactly two nodes. Switches the grid to projected coordinates,
+ * since a latitude/longitude grid cannot be rotated, and enables it.
+ * @see MapGridPaintable
+ * @see SetGridOriginAction
+ * @since 19640
+ */
+public class AlignGridRotationAction extends JosmAction {
+
+    /**
+     * Constructs a new {@code AlignGridRotationAction}.
+     */
+    public AlignGridRotationAction() {
+        super(tr("Align rotation to selection"), "gridrotate",
+                tr("Rotate the grid so that its lines are parallel to the selected way "
+                        + "(first to last node) or to the line between the two selected nodes. Switches to a projected grid."),
+                null, false);
+    }
+
+    /**
+     * Determines the direction given by the selection: a single way with at least two nodes, or exactly two nodes.
+     * @param ds the data set, may be null
+     * @return start and end point, or {@code null} if the selection does not define a direction
+     */
+    static EastNorth[] getSelectedDirection(DataSet ds) {
+        if (ds == null) {
+            return null;
+        }
+        Collection<Way> ways = ds.getSelectedWays();
+        Collection<Node> nodes = ds.getSelectedNodes();
+        Node a = null;
+        Node b = null;
+        if (ways.size() == 1 && nodes.isEmpty()) {
+            Way w = ways.iterator().next();
+            if (w.getNodesCount() >= 2) {
+                a = w.firstNode();
+                b = w.isClosed() ? w.getNode(1) : w.lastNode();
+            }
+        } else if (nodes.size() == 2 && ways.isEmpty()) {
+            Iterator<Node> it = nodes.iterator();
+            a = it.next();
+            b = it.next();
+        }
+        if (a == null || b == null || !a.isLatLonKnown() || !b.isLatLonKnown()) {
+            return null;
+        }
+        EastNorth[] result = {a.getEastNorth(), b.getEastNorth()};
+        return result[0].equalsEpsilon(result[1], 1e-9) ? null : result;
+    }
+
+    /**
+     * Computes the grid rotation (counter clockwise, in degrees, in the range [0, 90)) for which a grid line is
+     * parallel to the given direction.
+     * @param from start point
+     * @param to end point
+     * @return the rotation in degrees
+     */
+    public static double rotationOf(EastNorth from, EastNorth to) {
+        double angle = Math.toDegrees(Math.atan2(to.north() - from.north(), to.east() - from.east()));
+        angle %= 90;
+        if (angle < 0) {
+            angle += 90;
+        }
+        return angle >= 90 - 1e-9 ? 0 : angle;
+    }
+
+    @Override
+    protected void updateEnabledState() {
+        setEnabled(getSelectedDirection(getLayerManager().getEditDataSet()) != null);
+    }
+
+    @Override
+    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
+        updateEnabledState();
+    }
+
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        EastNorth[] direction = getSelectedDirection(getLayerManager().getEditDataSet());
+        if (direction == null) {
+            return;
+        }
+        MapGridPaintable.ROTATION.put(rotationOf(direction[0], direction[1]));
+        MapGridPaintable.TYPE.put(GridType.PROJECTED);
+        MapGridPaintable.ENABLED.put(true);
+        if (MainApplication.isDisplayingMapView()) {
+            MainApplication.getMap().mapView.repaint();
+        }
+    }
+}
Index: trunk/src/org/openstreetmap/josm/actions/SetGridOriginAction.java
===================================================================
--- trunk/src/org/openstreetmap/josm/actions/SetGridOriginAction.java	(revision 19640)
+++ trunk/src/org/openstreetmap/josm/actions/SetGridOriginAction.java	(revision 19640)
@@ -0,0 +1,112 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.actions;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+import java.util.Collection;
+
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.osm.DataSet;
+import org.openstreetmap.josm.data.osm.Node;
+import org.openstreetmap.josm.data.osm.OsmPrimitive;
+import org.openstreetmap.josm.data.osm.visitor.AllNodesVisitor;
+import org.openstreetmap.josm.data.projection.ProjectionRegistry;
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Moves the origin of the grid drawn over the map to the current selection, so that grid lines pass through it,
+ * and enables the grid. The origin is the position of the selected node, or the centroid (arithmetic mean
+ * position) of all nodes reachable from the current selection (nodes of selected ways, node members of selected
+ * relations) if more than one node is involved.
+ * @see MapGridPaintable
+ * @see AlignGridRotationAction
+ * @since 19640
+ */
+public class SetGridOriginAction extends JosmAction {
+
+    /**
+     * Constructs a new {@code SetGridOriginAction}.
+     */
+    public SetGridOriginAction() {
+        super(tr("Set origin to selection"), "gridorigin",
+                tr("Move the grid so that a grid line passes through the selected node, "
+                        + "or through the centroid of the current selection, and show the grid."),
+                null, false);
+    }
+
+    @Override
+    protected void updateEnabledState() {
+        setEnabled(getCentroid(getLayerManager().getEditDataSet()) != null);
+    }
+
+    @Override
+    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
+        updateEnabledState();
+    }
+
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        EastNorth centroid = getCentroid(getLayerManager().getEditDataSet());
+        if (centroid == null) {
+            return;
+        }
+        setOrigin(centroid);
+        if (MainApplication.isDisplayingMapView()) {
+            MainApplication.getMap().mapView.repaint();
+        }
+    }
+
+    /**
+     * Computes the centroid of the current selection: the (arithmetic mean) position of every node reachable from
+     * the selected primitives (the selected nodes themselves, the nodes of selected ways, and the node members of
+     * selected relations). For a single selected node this is simply that node's position.
+     * @param ds the data set, may be {@code null}
+     * @return the centroid, or {@code null} if the selection contains no node with known coordinates
+     */
+    static EastNorth getCentroid(DataSet ds) {
+        if (ds == null) {
+            return null;
+        }
+        double sumEast = 0;
+        double sumNorth = 0;
+        int count = 0;
+        for (Node n : AllNodesVisitor.getAllNodes(ds.getSelected())) {
+            if (n.isLatLonKnown()) {
+                EastNorth en = n.getEastNorth();
+                sumEast += en.east();
+                sumNorth += en.north();
+                count++;
+            }
+        }
+        return count == 0 ? null : new EastNorth(sumEast / count, sumNorth / count);
+    }
+
+    /**
+     * Sets the grid origin (in the coordinates of the current grid type) and enables the grid.
+     * @param position the new grid origin, in projected coordinates
+     */
+    public static void setOrigin(EastNorth position) {
+        if (MapGridPaintable.TYPE.get() == GridType.PROJECTED) {
+            MapGridPaintable.ORIGIN_X.put(position.east());
+            MapGridPaintable.ORIGIN_Y.put(position.north());
+        } else {
+            final LatLon ll;
+            try {
+                ll = ProjectionRegistry.getProjection().eastNorth2latlon(position);
+            } catch (IllegalArgumentException e) {
+                // the position is outside the domain of the projection, leave the grid as it is
+                Logging.warn("Cannot use {0} as grid origin: {1}", position, e.getMessage());
+                Logging.trace(e);
+                return;
+            }
+            MapGridPaintable.ORIGIN_X.put(ll.lon());
+            MapGridPaintable.ORIGIN_Y.put(ll.lat());
+        }
+        MapGridPaintable.ENABLED.put(true);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/actions/ShowGridAction.java
===================================================================
--- trunk/src/org/openstreetmap/josm/actions/ShowGridAction.java	(revision 19640)
+++ trunk/src/org/openstreetmap/josm/actions/ShowGridAction.java	(revision 19640)
@@ -0,0 +1,49 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.actions;
+
+import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.event.ActionEvent;
+
+import org.openstreetmap.josm.gui.MainApplication;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.tools.ImageProvider;
+
+/**
+ * This action toggles the display of the grid over the map view.
+ * @see MapGridPaintable
+ * @since 19640
+ */
+public class ShowGridAction extends PreferenceToggleAction {
+
+    /**
+     * Constructs a new {@link ShowGridAction}.
+     */
+    public ShowGridAction() {
+        super(tr("Show"),
+                new ImageProvider("grid"),
+                tr("Enable/disable the grid drawn over the map. Its spacing and orientation are set in the display preferences."),
+                MapGridPaintable.ENABLED
+        );
+        setHelpId(ht("/MapView#Grid"));
+    }
+
+    @Override
+    protected boolean listenToSelectionChange() {
+        return false;
+    }
+
+    @Override
+    protected void updateEnabledState() {
+        setEnabled(MainApplication.isDisplayingMapView());
+    }
+
+    @Override
+    public void actionPerformed(ActionEvent e) {
+        super.actionPerformed(e);
+        if (MainApplication.isDisplayingMapView()) {
+            MainApplication.getMap().mapView.repaint();
+        }
+    }
+}
Index: trunk/src/org/openstreetmap/josm/gui/MainMenu.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/MainMenu.java	(revision 19639)
+++ trunk/src/org/openstreetmap/josm/gui/MainMenu.java	(revision 19640)
@@ -31,4 +31,5 @@
 import org.openstreetmap.josm.actions.AboutAction;
 import org.openstreetmap.josm.actions.AddNodeAction;
+import org.openstreetmap.josm.actions.AlignGridRotationAction;
 import org.openstreetmap.josm.actions.AlignInCircleAction;
 import org.openstreetmap.josm.actions.AlignInLineAction;
@@ -101,4 +102,6 @@
 import org.openstreetmap.josm.actions.SessionSaveAction;
 import org.openstreetmap.josm.actions.SessionSaveAsAction;
+import org.openstreetmap.josm.actions.SetGridOriginAction;
+import org.openstreetmap.josm.actions.ShowGridAction;
 import org.openstreetmap.josm.actions.ShowStatusReportAction;
 import org.openstreetmap.josm.actions.SimplifyWayAction;
@@ -136,4 +139,5 @@
 import org.openstreetmap.josm.gui.layer.geoimage.WikimediaCommonsLoader.WikimediaCommonsLoadImagesAction;
 import org.openstreetmap.josm.gui.mappaint.MapPaintMenu;
+import org.openstreetmap.josm.gui.preferences.display.GridPreference;
 import org.openstreetmap.josm.gui.preferences.imagery.ImageryPreference;
 import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSearchPrimitiveDialog;
@@ -257,4 +261,12 @@
     /** View / Hatch area outside download */
     public final DrawBoundariesOfDownloadedDataAction drawBoundariesOfDownloadedDataAction = new DrawBoundariesOfDownloadedDataAction();
+    /** View / Grid submenu: the grid drawn over the map and its placement */
+    public final JMenu gridMenu = new JMenu(tr("Grid"));
+    /** View / Grid / Show */
+    public final ShowGridAction showGridAction = new ShowGridAction();
+    /** View / Grid / Set origin to selection */
+    public final SetGridOriginAction setGridOriginAction = new SetGridOriginAction();
+    /** View / Grid / Align rotation to selection */
+    public final AlignGridRotationAction alignGridRotationAction = new AlignGridRotationAction();
     /** View / Advanced info */
     public final InfoAction info = new InfoAction();
@@ -816,4 +828,13 @@
         viewMenu.add(hatchAreaOutsideDownloadMenuItem);
         ExpertToggleAction.addVisibilitySwitcher(hatchAreaOutsideDownloadMenuItem);
+        // -- Grid submenu
+        gridMenu.setIcon(ImageProvider.get("grid", ImageProvider.ImageSizes.MENU));
+        gridMenu.add(showGridAction.getCheckbox());
+        add(gridMenu, setGridOriginAction);
+        add(gridMenu, alignGridRotationAction);
+        gridMenu.addSeparator();
+        add(gridMenu, PreferencesAction.forPreferenceTab(tr("Grid preferences..."),
+                tr("Click to open the grid tab in the preferences"), GridPreference.class));
+        viewMenu.add(gridMenu);
 
         viewMenu.add(new MapPaintMenu());
Index: trunk/src/org/openstreetmap/josm/gui/MapFrame.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/MapFrame.java	(revision 19639)
+++ trunk/src/org/openstreetmap/josm/gui/MapFrame.java	(revision 19640)
@@ -74,4 +74,5 @@
 import org.openstreetmap.josm.gui.dialogs.properties.PropertiesDialog;
 import org.openstreetmap.josm.gui.layer.Layer;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
 import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
 import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
@@ -123,4 +124,6 @@
      */
     public final MapView mapView;
+    /** The grid drawn over the map view, see {@link MapGridPaintable} */
+    private final MapGridPaintable gridOverlay = new MapGridPaintable();
 
     /**
@@ -205,4 +208,5 @@
 
         mapView = new MapView(MainApplication.getLayerManager(), viewportData);
+        mapView.addTemporaryLayer(gridOverlay);
 
         splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, true);
@@ -367,4 +371,6 @@
 
         statusLine.destroy();
+        mapView.removeTemporaryLayer(gridOverlay);
+        gridOverlay.destroy();
         mapView.destroy();
         keyDetector.unregister();
Index: trunk/src/org/openstreetmap/josm/gui/draw/BlendComposite.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/draw/BlendComposite.java	(revision 19640)
+++ trunk/src/org/openstreetmap/josm/gui/draw/BlendComposite.java	(revision 19640)
@@ -0,0 +1,188 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.draw;
+
+import java.awt.Composite;
+import java.awt.CompositeContext;
+import java.awt.RenderingHints;
+import java.awt.image.ColorModel;
+import java.awt.image.Raster;
+import java.awt.image.WritableRaster;
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * A {@link Composite} implementing the common "blend modes" of image editors (multiply, burn, hard light,
+ * difference, divide), which the standard {@link java.awt.AlphaComposite} does not provide.
+ * <p>
+ * The source alpha (including the coverage produced by antialiasing) controls how strongly the blended color
+ * replaces the destination, following the "source over" rule of the W3C compositing model, so that a
+ * translucent destination is handled correctly as well. It works on any color model, but is implemented per
+ * pixel and thus only meant for drawing thin shapes such as grid lines. It must be used on
+ * {@link java.awt.image.BufferedImage} backed graphics, since hardware accelerated pipelines do not support
+ * custom composites.
+ * @since 19640
+ */
+public final class BlendComposite implements Composite {
+
+    /**
+     * The supported blend modes.
+     */
+    public enum Mode {
+        /** Normal alpha blending (like {@link java.awt.AlphaComposite#SrcOver}) */
+        NORMAL,
+        /** Multiplies source and destination: darkens, white is neutral */
+        MULTIPLY,
+        /** Color burn: darkens and increases contrast, white is neutral */
+        BURN,
+        /** Hard light: multiplies for dark sources, screens for light sources; strong contrast */
+        HARD_LIGHT,
+        /** Absolute difference between source and destination: always visible on any background */
+        DIFFERENCE,
+        /** Divides destination by source: lightens, white is neutral, dark sources give bright lines */
+        DIVIDE
+    }
+
+    private static final Map<Mode, BlendComposite> INSTANCES = new EnumMap<>(Mode.class);
+
+    private final Mode mode;
+
+    private BlendComposite(Mode mode) {
+        this.mode = mode;
+    }
+
+    /**
+     * Returns the composite for the given mode.
+     * @param mode the blend mode
+     * @return the composite
+     */
+    public static synchronized BlendComposite getInstance(Mode mode) {
+        return INSTANCES.computeIfAbsent(mode, BlendComposite::new);
+    }
+
+    /**
+     * Returns the blend mode of this composite.
+     * @return the blend mode
+     */
+    public Mode getMode() {
+        return mode;
+    }
+
+    @Override
+    public CompositeContext createContext(ColorModel srcColorModel, ColorModel dstColorModel, RenderingHints hints) {
+        return new BlendContext(mode, srcColorModel, dstColorModel);
+    }
+
+    /**
+     * Blends one color channel.
+     * @param mode blend mode
+     * @param s source channel value (0-255)
+     * @param d destination channel value (0-255)
+     * @return blended value (0-255)
+     */
+    static int blend(Mode mode, int s, int d) {
+        switch (mode) {
+        case MULTIPLY:
+            return s * d / 255;
+        case BURN:
+            return s == 0 ? 0 : 255 - Math.min(255, (255 - d) * 255 / s);
+        case HARD_LIGHT:
+            return s < 128 ? 2 * s * d / 255 : 255 - (255 - d) * (510 - 2 * s) / 255;
+        case DIFFERENCE:
+            return Math.abs(s - d);
+        case DIVIDE:
+            return s == 0 ? 255 : Math.min(255, d * 255 / s);
+        case NORMAL:
+        default:
+            return s;
+        }
+    }
+
+    /**
+     * Composes one pixel: blends the source color with the destination and combines the two with the
+     * "source over" rule, so that both the source alpha (including the coverage produced by antialiasing)
+     * and a translucent destination are handled correctly.
+     * @param mode blend mode
+     * @param s source color (ARGB, not premultiplied)
+     * @param d destination color (ARGB, not premultiplied)
+     * @return the resulting color (ARGB, not premultiplied)
+     */
+    static int composePixel(Mode mode, int s, int d) {
+        int sa = s >>> 24;
+        if (sa == 0) {
+            return d;
+        }
+        int da = d >>> 24;
+        if (da == 0xff) {
+            // opaque destination (the map view): the result is the destination moved towards the blended color
+            return 0xff000000
+                    | (mix(blend(mode, (s >> 16) & 0xff, (d >> 16) & 0xff), (d >> 16) & 0xff, sa) << 16)
+                    | (mix(blend(mode, (s >> 8) & 0xff, (d >> 8) & 0xff), (d >> 8) & 0xff, sa) << 8)
+                    | mix(blend(mode, s & 0xff, d & 0xff), d & 0xff, sa);
+        }
+        int a = sa + da * (0xff - sa) / 0xff;
+        if (a == 0) {
+            return 0;
+        }
+        return (a << 24)
+                | (composeChannel(mode, (s >> 16) & 0xff, (d >> 16) & 0xff, sa, da, a) << 16)
+                | (composeChannel(mode, (s >> 8) & 0xff, (d >> 8) & 0xff, sa, da, a) << 8)
+                | composeChannel(mode, s & 0xff, d & 0xff, sa, da, a);
+    }
+
+    /**
+     * Composes one color channel of a translucent destination, see
+     * <a href="https://www.w3.org/TR/compositing-1/#blending">the W3C compositing model</a>:
+     * {@code co = as*(1-ab)*Cs + as*ab*B(Cb,Cs) + (1-as)*ab*Cb} and {@code Co = co/ao}.
+     * @param mode blend mode
+     * @param cs source channel value (0-255)
+     * @param cb destination (backdrop) channel value (0-255)
+     * @param sa source alpha (0-255)
+     * @param da destination alpha (0-255)
+     * @param a the resulting alpha (0-255), must not be 0
+     * @return the resulting channel value (0-255)
+     */
+    private static int composeChannel(Mode mode, int cs, int cb, int sa, int da, int a) {
+        int co = sa * (0xff - da) * cs + sa * da * blend(mode, cs, cb) + (0xff - sa) * da * cb;
+        return co / (0xff * a);
+    }
+
+    /** linear interpolation between d (alpha 0) and s (alpha 255) */
+    private static int mix(int s, int d, int alpha) {
+        return d + (s - d) * alpha / 0xff;
+    }
+
+    private static final class BlendContext implements CompositeContext {
+        private final Mode mode;
+        private final ColorModel srcColorModel;
+        private final ColorModel dstColorModel;
+
+        BlendContext(Mode mode, ColorModel srcColorModel, ColorModel dstColorModel) {
+            this.mode = mode;
+            this.srcColorModel = srcColorModel;
+            this.dstColorModel = dstColorModel;
+        }
+
+        @Override
+        public void compose(Raster src, Raster dstIn, WritableRaster dstOut) {
+            int w = Math.min(Math.min(src.getWidth(), dstIn.getWidth()), dstOut.getWidth());
+            int h = Math.min(Math.min(src.getHeight(), dstIn.getHeight()), dstOut.getHeight());
+            Object srcPixel = null;
+            Object dstPixel = null;
+            Object outPixel = null;
+            for (int y = 0; y < h; y++) {
+                for (int x = 0; x < w; x++) {
+                    srcPixel = src.getDataElements(x, y, srcPixel);
+                    dstPixel = dstIn.getDataElements(x, y, dstPixel);
+                    int result = composePixel(mode, srcColorModel.getRGB(srcPixel), dstColorModel.getRGB(dstPixel));
+                    outPixel = dstColorModel.getDataElements(result, outPixel);
+                    dstOut.setDataElements(x, y, outPixel);
+                }
+            }
+        }
+
+        @Override
+        public void dispose() {
+            // nothing to dispose
+        }
+    }
+}
Index: trunk/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java	(revision 19639)
+++ trunk/src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java	(revision 19640)
@@ -5,4 +5,5 @@
 import static org.openstreetmap.josm.tools.I18n.tr;
 
+import java.awt.BasicStroke;
 import java.awt.Color;
 import java.awt.Component;
@@ -16,4 +17,5 @@
 import java.awt.Shape;
 import java.awt.Toolkit;
+import java.awt.Stroke;
 import java.awt.event.ActionEvent;
 import java.awt.event.MouseAdapter;
@@ -94,4 +96,5 @@
 import org.openstreetmap.josm.data.preferences.BooleanProperty;
 import org.openstreetmap.josm.data.preferences.IntegerProperty;
+import org.openstreetmap.josm.data.preferences.NamedColorProperty;
 import org.openstreetmap.josm.data.projection.Projection;
 import org.openstreetmap.josm.data.projection.ProjectionRegistry;
@@ -117,4 +120,5 @@
 import org.openstreetmap.josm.gui.layer.imagery.ReprojectionTile;
 import org.openstreetmap.josm.gui.layer.imagery.ShowErrorsAction;
+import org.openstreetmap.josm.gui.layer.imagery.ShowTileBordersAction;
 import org.openstreetmap.josm.gui.layer.imagery.TileAnchor;
 import org.openstreetmap.josm.gui.layer.imagery.TileCoordinateConverter;
@@ -182,4 +186,7 @@
 
     private static final BooleanProperty POPUP_MENU_ENABLED = new BooleanProperty(PREFERENCE_PREFIX + ".popupmenu", true);
+    /** Color of the border drawn around each tile if enabled, see {@link TileSourceDisplaySettings#isShowTileBorders()} */
+    private static final NamedColorProperty TILE_BORDER_COLOR = new NamedColorProperty(marktr("imagery tile border"), new Color(0, 0, 0, 96));
+    private static final Stroke TILE_BORDER_STROKE = new BasicStroke(1f);
     private static final String ERROR_STRING = marktr("Error");
 
@@ -1227,5 +1234,14 @@
         }
 
-        if (Logging.isDebugEnabled()) {
+        if (getDisplaySettings().isShowTileBorders()) {
+            // draw a thin border around the tile
+            Color oldColor = g.getColor();
+            Stroke oldStroke = g.getStroke();
+            g.setColor(TILE_BORDER_COLOR.get());
+            g.setStroke(TILE_BORDER_STROKE);
+            g.draw(coordinateConverter.getTileShapeScreen(tile));
+            g.setStroke(oldStroke);
+            g.setColor(oldColor);
+        } else if (Logging.isDebugEnabled()) {
             // draw tile outline in semi-transparent red
             g.setColor(new Color(255, 0, 0, 50));
@@ -1854,4 +1870,5 @@
             new AutoZoomAction(this),
             new ShowErrorsAction(this),
+            new ShowTileBordersAction(this),
             new IncreaseZoomAction(this),
             new DecreaseZoomAction(this),
Index: trunk/src/org/openstreetmap/josm/gui/layer/MapGridPaintable.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/MapGridPaintable.java	(revision 19640)
+++ trunk/src/org/openstreetmap/josm/gui/layer/MapGridPaintable.java	(revision 19640)
@@ -0,0 +1,508 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.layer;
+
+import static org.openstreetmap.josm.tools.I18n.marktr;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.geom.Line2D;
+import java.awt.geom.Path2D;
+import java.awt.geom.Point2D;
+import java.util.ArrayList;
+import java.util.List;
+
+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.ILatLon;
+import org.openstreetmap.josm.data.coor.LatLon;
+import org.openstreetmap.josm.data.preferences.BooleanProperty;
+import org.openstreetmap.josm.data.preferences.DoubleProperty;
+import org.openstreetmap.josm.data.preferences.EnumProperty;
+import org.openstreetmap.josm.data.preferences.NamedColorProperty;
+import org.openstreetmap.josm.data.projection.Projection;
+import org.openstreetmap.josm.gui.MapView;
+import org.openstreetmap.josm.gui.draw.BlendComposite;
+import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
+import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
+import org.openstreetmap.josm.tools.Destroyable;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * A grid drawn over the whole map view, on top of all layers.
+ * <p>
+ * The grid is a pure visual aid (there is no snapping to it). It is either aligned to latitude/longitude, with a
+ * spacing in degrees, or to the projected coordinates, with a spacing in metres (true distance, measured at the
+ * grid origin), an optional rotation and an origin offset. When the grid cells would become
+ * smaller than a minimal size on screen, the spacing is multiplied by 10 until the cells are large enough, so the
+ * grid stays readable at every zoom level while remaining aligned to the configured one.
+ * <p>
+ * All settings are preferences (prefix {@code draw.grid.}), see {@link org.openstreetmap.josm.gui.preferences.display.GridPreference}.
+ * <p>
+ * An instance registers itself as a preference listener, so {@link #destroy()} must be called when it is no
+ * longer used ({@link org.openstreetmap.josm.gui.MapFrame} does this), otherwise the listener is leaked.
+ * @since 19640
+ */
+public class MapGridPaintable extends AbstractMapViewPaintable implements PreferenceChangedListener, Destroyable {
+
+    /**
+     * The kind of coordinates a grid is aligned to.
+     */
+    public enum GridType {
+        /** lines of constant latitude and longitude, spacing in degrees */
+        LATLON,
+        /** lines of constant projected east/north coordinate (optionally rotated), spacing in metres */
+        PROJECTED
+    }
+
+    private static final String PREFIX = "draw.grid.";
+
+    /** Whether the grid is shown */
+    public static final BooleanProperty ENABLED = new BooleanProperty(PREFIX + "enabled", false);
+    /** The kind of grid */
+    public static final EnumProperty<GridType> TYPE = new EnumProperty<>(PREFIX + "type", GridType.class, GridType.PROJECTED);
+    /** Spacing of the vertical lines (longitude resp. east), in degrees resp. metres (true distance at the origin) */
+    public static final DoubleProperty SPACING_X = new DoubleProperty(PREFIX + "spacing-x", 1000);
+    /** Spacing of the horizontal lines (latitude resp. north), in degrees resp. metres (true distance at the origin) */
+    public static final DoubleProperty SPACING_Y = new DoubleProperty(PREFIX + "spacing-y", 1000);
+    /** Rotation of a projected grid, in degrees counter clockwise */
+    public static final DoubleProperty ROTATION = new DoubleProperty(PREFIX + "rotation", 0);
+    /** Origin of the grid: a grid line passes through this coordinate (longitude resp. east) */
+    public static final DoubleProperty ORIGIN_X = new DoubleProperty(PREFIX + "origin-x", 0);
+    /** Origin of the grid: a grid line passes through this coordinate (latitude resp. north) */
+    public static final DoubleProperty ORIGIN_Y = new DoubleProperty(PREFIX + "origin-y", 0);
+    /** Below this distance between lines (in pixels) the spacing is multiplied by 10 */
+    public static final DoubleProperty MIN_PIXEL_SPACING = new DoubleProperty(PREFIX + "min-pixel-spacing", 25);
+    /** The blend mode used to draw the lines */
+    public static final EnumProperty<BlendComposite.Mode> BLEND_MODE
+            = new EnumProperty<>(PREFIX + "blend-mode", BlendComposite.Mode.class, BlendComposite.Mode.HARD_LIGHT);
+    /** Width of the lines in pixels */
+    public static final DoubleProperty LINE_WIDTH = new DoubleProperty(PREFIX + "line-width", 2);
+    /** Color of the lines of constant east coordinate resp. longitude (the lines running north-south) */
+    public static final NamedColorProperty COLOR_EAST = new NamedColorProperty(marktr("grid east lines"), Color.YELLOW);
+    /** Color of the lines of constant north coordinate resp. latitude (the lines running east-west) */
+    public static final NamedColorProperty COLOR_NORTH = new NamedColorProperty(marktr("grid north lines"), Color.YELLOW);
+
+    /**
+     * A straight line of a projected grid.
+     * @since 19640
+     */
+    public static final class ProjectedGridLine {
+        /** start point */
+        public final EastNorth start;
+        /** end point */
+        public final EastNorth end;
+        /** {@code true} for a line of constant (rotated) east coordinate, {@code false} for constant north */
+        public final boolean constantEast;
+
+        ProjectedGridLine(EastNorth start, EastNorth end, boolean constantEast) {
+            this.start = start;
+            this.end = end;
+            this.constantEast = constantEast;
+        }
+
+        @Override
+        public String toString() {
+            return (constantEast ? "east " : "north ") + start + " -> " + end;
+        }
+    }
+
+    /**
+     * A (curved) line of a latitude/longitude grid.
+     * @since 19640
+     */
+    public static final class LatLonGridLine {
+        /** the points of the polyline */
+        public final List<LatLon> points;
+        /** {@code true} for a meridian (constant longitude), {@code false} for a parallel (constant latitude) */
+        public final boolean meridian;
+
+        LatLonGridLine(List<LatLon> points, boolean meridian) {
+            this.points = points;
+            this.meridian = meridian;
+        }
+
+        @Override
+        public String toString() {
+            return (meridian ? "meridian " : "parallel ") + points;
+        }
+    }
+
+    /** number of segments used to draw a curved (lat/lon) grid line across the view */
+    private static final int CURVE_SEGMENTS = 32;
+    /** hard limit for the number of lines in one direction, whatever the settings are */
+    private static final int MAX_LINES = 500;
+
+    /** written by {@link #paint} and read by {@link #preferenceChanged}, which may run on another thread */
+    private volatile MapView mapView;
+
+    /**
+     * Constructs a new {@code MapGridPaintable}.
+     */
+    public MapGridPaintable() {
+        Config.getPref().addPreferenceChangeListener(this);
+    }
+
+    @Override
+    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
+        mapView = mv;
+        if (!Boolean.TRUE.equals(ENABLED.get()) || mv.getWidth() <= 0 || mv.getHeight() <= 0) {
+            return;
+        }
+        Graphics2D g2 = (Graphics2D) g.create();
+        try {
+            g2.setStroke(new BasicStroke((float) Math.max(0.1, LINE_WIDTH.get())));
+            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                    Config.getPref().getBoolean("mappaint.use-antialiasing", true)
+                    ? RenderingHints.VALUE_ANTIALIAS_ON : RenderingHints.VALUE_ANTIALIAS_OFF);
+            BlendComposite.Mode mode = BLEND_MODE.get();
+            if (mode != null && mode != BlendComposite.Mode.NORMAL) {
+                g2.setComposite(BlendComposite.getInstance(mode));
+            }
+            if (TYPE.get() == GridType.PROJECTED) {
+                paintProjectedGrid(g2, mv);
+            } else {
+                paintLatLonGrid(g2, mv);
+            }
+        } finally {
+            g2.dispose();
+        }
+    }
+
+    private static void paintProjectedGrid(Graphics2D g, MapView mv) {
+        double sx = SPACING_X.get();
+        double sy = SPACING_Y.get();
+        if (sx <= 0 || sy <= 0) {
+            return;
+        }
+        // the spacing is a true distance in metres, measured at the grid origin
+        EastNorth origin = new EastNorth(ORIGIN_X.get(), ORIGIN_Y.get());
+        double unitsPerMetre = projectionUnitsPerMetre(mv.getProjection(), origin);
+        sx *= unitsPerMetre;
+        sy *= unitsPerMetre;
+        // thin out the grid until the cells are large enough on screen
+        double factor = thinningFactor(Math.min(sx, sy) / mv.getScale());
+        List<ProjectedGridLine> lines = getProjectedGridLines(mv.getProjectionBounds(), sx * factor, sy * factor,
+                ROTATION.get(), origin.east(), origin.north());
+        for (ProjectedGridLine line : lines) {
+            g.setColor(line.constantEast ? COLOR_EAST.get() : COLOR_NORTH.get());
+            g.draw(new Line2D.Double(mv.getPoint2D(line.start), mv.getPoint2D(line.end)));
+        }
+    }
+
+    private static void paintLatLonGrid(Graphics2D g, MapView mv) {
+        double sx = SPACING_X.get();
+        double sy = SPACING_Y.get();
+        if (sx <= 0 || sy <= 0) {
+            return;
+        }
+        Bounds view = getVisibleLatLonBounds(mv);
+        if (view == null) {
+            return;
+        }
+        // pixel size of one cell at the center of the view
+        LatLon center = view.getCenter();
+        Point2D c = mv.getPoint2D(center);
+        double factor = thinningFactor(Math.min(probePixels(mv, c, center, sx, true), probePixels(mv, c, center, sy, false)));
+        List<LatLonGridLine> lines = getLatLonGridLines(view, mv.getProjection().getWorldBoundsLatLon(),
+                sx * factor, sy * factor, ORIGIN_X.get(), ORIGIN_Y.get(), CURVE_SEGMENTS);
+        for (LatLonGridLine line : lines) {
+            g.setColor(line.meridian ? COLOR_EAST.get() : COLOR_NORTH.get());
+            Path2D.Double path = new Path2D.Double();
+            boolean first = true;
+            for (LatLon ll : line.points) {
+                Point2D p = mv.getPoint2D(ll);
+                if (first) {
+                    path.moveTo(p.getX(), p.getY());
+                    first = false;
+                } else {
+                    path.lineTo(p.getX(), p.getY());
+                }
+            }
+            g.draw(path);
+        }
+    }
+
+    /**
+     * Computes the latitude/longitude bounds of the part of the world which is visible in the given view.
+     * <p>
+     * Contrary to {@link org.openstreetmap.josm.gui.NavigatableComponent#getRealBounds()} this does not simply
+     * convert the corners of the view: as soon as the view is larger than the world, those lie outside the world
+     * and their longitude wraps around, which yields a range much narrower than what is really visible (and one
+     * which jumps around while zooming or panning). The view is therefore first clipped to the world in
+     * projected coordinates.
+     * <p>
+     * The clipped area is kept a hair inside the world, because a point exactly on the antimeridian converts to
+     * an ambiguous longitude: {@link Projection#eastNorth2latlon} normalizes it to -180, which would turn the
+     * visible range of a view showing e.g. 60° E to 180° into 180° W to 60° E, i.e. the other half of the world.
+     * @param mv the map view
+     * @return the visible bounds, or {@code null} if no part of the world is visible
+     */
+    static Bounds getVisibleLatLonBounds(MapView mv) {
+        Projection projection = mv.getProjection();
+        ProjectionBounds view = mv.getProjectionBounds();
+        ProjectionBounds world = projection.getWorldBoundsBoxEastNorth();
+        double minEast = Math.max(view.minEast, world.minEast);
+        double maxEast = Math.min(view.maxEast, world.maxEast);
+        double minNorth = Math.max(view.minNorth, world.minNorth);
+        double maxNorth = Math.min(view.maxNorth, world.maxNorth);
+        if (minEast >= maxEast || minNorth >= maxNorth) {
+            return null;
+        }
+        // stay inside the world, but never collapse the area (the inset is a fraction of a millimetre on the ground)
+        double insetEast = Math.min((maxEast - minEast) / 4, (world.maxEast - world.minEast) * 1e-9);
+        double insetNorth = Math.min((maxNorth - minNorth) / 4, (world.maxNorth - world.minNorth) * 1e-9);
+        minEast += insetEast;
+        maxEast -= insetEast;
+        minNorth += insetNorth;
+        maxNorth -= insetNorth;
+        Bounds bounds = null;
+        for (double east : new double[] {minEast, maxEast}) {
+            for (double north : new double[] {minNorth, maxNorth}) {
+                LatLon ll = projection.eastNorth2latlon(new EastNorth(east, north));
+                if (ll.isValid()) {
+                    if (bounds == null) {
+                        bounds = new Bounds(ll, false);
+                    } else {
+                        bounds.extend(ll);
+                    }
+                }
+            }
+        }
+        return bounds != null ? bounds : mv.getRealBounds();
+    }
+
+    /**
+     * Measures the distance on screen which corresponds to one grid spacing at the center of the view. The probe
+     * is placed on whichever side of the center stays inside the valid coordinate range, and it is shortened if
+     * the spacing itself does not fit, so that the result is a usable length at every zoom level.
+     * @param mv the map view
+     * @param center the center of the view, on screen
+     * @param at the center of the view
+     * @param spacing the grid spacing, in degrees
+     * @param lon {@code true} to probe along the longitude, {@code false} along the latitude
+     * @return the distance in pixels; 0 if it cannot be measured
+     */
+    private static double probePixels(MapView mv, Point2D center, LatLon at, double spacing, boolean lon) {
+        double max = lon ? 180 : 89;
+        double delta = Math.min(spacing, max);
+        double value = lon ? at.lon() : at.lat();
+        // probe towards the pole resp. the antimeridian, or backwards if that would leave the valid range
+        double probe = value + delta <= max ? value + delta : value - delta;
+        if (probe < -max || probe > max) {
+            return 0;
+        }
+        Point2D p = mv.getPoint2D(lon ? new LatLon(at.lat(), probe) : new LatLon(probe, at.lon()));
+        return center.distance(p) * spacing / delta;
+    }
+
+    /**
+     * Computes how many projection units correspond to one metre on the ground at the given position. For
+     * conformal projections (e.g. Mercator) this is the local scale factor, for a Mercator grid at 60° latitude
+     * one metre is two projection units.
+     * @param projection the projection
+     * @param at the position (projected coordinates)
+     * @return projection units per metre; 1 if it cannot be determined (position outside the world)
+     */
+    public static double projectionUnitsPerMetre(Projection projection, EastNorth at) {
+        try {
+            if (!projection.getWorldBoundsBoxEastNorth().contains(at)) {
+                return 1;
+            }
+            double step = 100;
+            LatLon a = projection.eastNorth2latlon(at);
+            LatLon b = projection.eastNorth2latlon(new EastNorth(at.east() + step, at.north()));
+            if (!a.isValid() || !b.isValid()) {
+                return 1;
+            }
+            double metres = a.greatCircleDistance((ILatLon) b);
+            return metres > 0 && Double.isFinite(metres) ? step / metres : 1;
+        } catch (IllegalArgumentException e) {
+            Logging.trace(e);
+            return 1;
+        }
+    }
+
+    /**
+     * Computes the factor (a power of 10) by which the spacing must be multiplied so that the lines are at least
+     * {@link #MIN_PIXEL_SPACING} apart.
+     * @param pixelSpacing the distance between two lines on screen, in pixels
+     * @return the factor (at least 1)
+     */
+    static double thinningFactor(double pixelSpacing) {
+        double min = Math.max(1, MIN_PIXEL_SPACING.get());
+        double factor = 1;
+        if (pixelSpacing <= 0 || Double.isNaN(pixelSpacing)) {
+            return factor;
+        }
+        while (pixelSpacing * factor < min && factor < 1e15) {
+            factor *= 10;
+        }
+        return factor;
+    }
+
+    /**
+     * Computes the lines of a (possibly rotated) grid in projected coordinates which cross the given area.
+     * @param area the area to cover
+     * @param spacingX distance between the lines running in the "north" direction of the grid (before rotation)
+     * @param spacingY distance between the lines running in the "east" direction of the grid (before rotation)
+     * @param rotationDegrees rotation of the grid, counter clockwise
+     * @param originX east coordinate of the grid origin
+     * @param originY north coordinate of the grid origin
+     * @return the lines; empty if the spacing is invalid or there would be too many lines
+     */
+    public static List<ProjectedGridLine> getProjectedGridLines(ProjectionBounds area, double spacingX, double spacingY,
+            double rotationDegrees, double originX, double originY) {
+        List<ProjectedGridLine> lines = new ArrayList<>();
+        if (!(spacingX > 0) || !(spacingY > 0)) {
+            return lines;
+        }
+        double angle = Math.toRadians(rotationDegrees);
+        double c = Math.cos(angle);
+        double s = Math.sin(angle);
+        // grid coordinates (u along the rotated east axis, v along the rotated north axis) of the area corners
+        double[] xs = {area.minEast, area.maxEast, area.maxEast, area.minEast};
+        double[] ys = {area.minNorth, area.minNorth, area.maxNorth, area.maxNorth};
+        double uMin = Double.POSITIVE_INFINITY;
+        double uMax = Double.NEGATIVE_INFINITY;
+        double vMin = Double.POSITIVE_INFINITY;
+        double vMax = Double.NEGATIVE_INFINITY;
+        for (int i = 0; i < 4; i++) {
+            double dx = xs[i] - originX;
+            double dy = ys[i] - originY;
+            double u = c * dx + s * dy;
+            double v = -s * dx + c * dy;
+            uMin = Math.min(uMin, u);
+            uMax = Math.max(uMax, u);
+            vMin = Math.min(vMin, v);
+            vMax = Math.max(vMax, v);
+        }
+        long kuMin = (long) Math.ceil(uMin / spacingX);
+        long kuMax = (long) Math.floor(uMax / spacingX);
+        long kvMin = (long) Math.ceil(vMin / spacingY);
+        long kvMax = (long) Math.floor(vMax / spacingY);
+        if (kuMax - kuMin > MAX_LINES || kvMax - kvMin > MAX_LINES) {
+            return lines;
+        }
+        // lines of constant u run along the v axis
+        for (long k = kuMin; k <= kuMax; k++) {
+            double u = k * spacingX;
+            lines.add(new ProjectedGridLine(gridToEastNorth(u, vMin, c, s, originX, originY),
+                    gridToEastNorth(u, vMax, c, s, originX, originY), true));
+        }
+        for (long k = kvMin; k <= kvMax; k++) {
+            double v = k * spacingY;
+            lines.add(new ProjectedGridLine(gridToEastNorth(uMin, v, c, s, originX, originY),
+                    gridToEastNorth(uMax, v, c, s, originX, originY), false));
+        }
+        return lines;
+    }
+
+    private static EastNorth gridToEastNorth(double u, double v, double c, double s, double originX, double originY) {
+        return new EastNorth(originX + c * u - s * v, originY + s * u + c * v);
+    }
+
+    /**
+     * Computes the lines of a latitude/longitude grid which cross the given area. Since these lines are curves in
+     * most projections, each line is returned as a polyline.
+     * @param area the area to cover
+     * @param world the bounds of the world in the current projection, the lines are clamped to it
+     * @param spacingLon distance between the meridians, in degrees
+     * @param spacingLat distance between the parallels, in degrees
+     * @param originLon longitude of a meridian of the grid
+     * @param originLat latitude of a parallel of the grid
+     * @param segments number of segments of each polyline
+     * @return the lines; empty if the spacing is invalid or there would be too many lines
+     */
+    public static List<LatLonGridLine> getLatLonGridLines(Bounds area, Bounds world, double spacingLon, double spacingLat,
+            double originLon, double originLat, int segments) {
+        List<LatLonGridLine> lines = new ArrayList<>();
+        if (!(spacingLon > 0) || !(spacingLat > 0) || segments < 1) {
+            return lines;
+        }
+        double minLat = Math.max(area.getMinLat(), world.getMinLat());
+        double maxLat = Math.min(area.getMaxLat(), world.getMaxLat());
+        double minLon = area.getMinLon();
+        double maxLon = area.getMaxLon();
+        if (maxLon < minLon) {
+            maxLon += 360; // the view crosses the antimeridian
+        }
+        if (minLat >= maxLat || minLon >= maxLon) {
+            return lines;
+        }
+        long kLonMin = (long) Math.ceil((minLon - originLon) / spacingLon);
+        long kLonMax = (long) Math.floor((maxLon - originLon) / spacingLon);
+        long kLatMin = (long) Math.ceil((minLat - originLat) / spacingLat);
+        long kLatMax = (long) Math.floor((maxLat - originLat) / spacingLat);
+        if (kLonMax - kLonMin > MAX_LINES || kLatMax - kLatMin > MAX_LINES) {
+            return lines;
+        }
+        // for a projection which does not span the whole globe, meridians outside its longitude range are
+        // skipped like the parallels below; a full range must not be checked since longitudes wrap around
+        boolean limitedLon = world.getMinLon() > -180 || world.getMaxLon() < 180;
+        for (long k = kLonMin; k <= kLonMax; k++) {
+            double lon = LatLon.toIntervalLon(originLon + k * spacingLon);
+            if (limitedLon && (lon < world.getMinLon() || lon > world.getMaxLon())) {
+                continue;
+            }
+            List<LatLon> line = new ArrayList<>(segments + 1);
+            for (int i = 0; i <= segments; i++) {
+                line.add(new LatLon(minLat + (maxLat - minLat) * i / segments, lon));
+            }
+            lines.add(new LatLonGridLine(line, true));
+        }
+        for (long k = kLatMin; k <= kLatMax; k++) {
+            double lat = originLat + k * spacingLat;
+            if (lat < world.getMinLat() || lat > world.getMaxLat()) {
+                continue;
+            }
+            addParallel(lines, lat, minLon, maxLon, segments);
+        }
+        return lines;
+    }
+
+    /**
+     * Adds a parallel running from one longitude to another. A parallel which crosses the antimeridian is added
+     * as two lines, one on each side of it: wrapping the longitudes of a single polyline would instead make it
+     * jump right across the view.
+     * @param lines the list to add to
+     * @param lat the latitude of the parallel
+     * @param minLon the longitude to start at, in [-180, 180]
+     * @param maxLon the longitude to end at, may be larger than 180 if the area crosses the antimeridian
+     * @param segments number of segments of each polyline
+     */
+    private static void addParallel(List<LatLonGridLine> lines, double lat, double minLon, double maxLon, int segments) {
+        if (maxLon > 180) {
+            addParallel(lines, lat, minLon, 180, segments);
+            addParallel(lines, lat, -180, maxLon - 360, segments);
+            return;
+        }
+        if (!(maxLon > minLon)) {
+            return;
+        }
+        List<LatLon> line = new ArrayList<>(segments + 1);
+        for (int i = 0; i <= segments; i++) {
+            line.add(new LatLon(lat, minLon + (maxLon - minLon) * i / segments));
+        }
+        lines.add(new LatLonGridLine(line, false));
+    }
+
+    @Override
+    public void preferenceChanged(PreferenceChangeEvent e) {
+        if (e.getKey().startsWith(PREFIX) || e.getKey().equals(COLOR_EAST.getKey()) || e.getKey().equals(COLOR_NORTH.getKey())) {
+            invalidate();
+            if (mapView != null) {
+                mapView.repaint();
+            }
+        }
+    }
+
+    @Override
+    public void destroy() {
+        Config.getPref().removePreferenceChangeListener(this);
+        mapView = null;
+    }
+}
Index: trunk/src/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersAction.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersAction.java	(revision 19640)
+++ trunk/src/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersAction.java	(revision 19640)
@@ -0,0 +1,55 @@
+// 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.Component;
+import java.awt.event.ActionEvent;
+import java.util.List;
+
+import javax.swing.AbstractAction;
+import javax.swing.JCheckBoxMenuItem;
+
+import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
+import org.openstreetmap.josm.gui.layer.Layer;
+import org.openstreetmap.josm.gui.layer.Layer.LayerAction;
+
+/**
+ * Toggles the drawing of a thin border around each tile of an imagery layer.
+ * @since 19640
+ */
+public class ShowTileBordersAction extends AbstractAction implements LayerAction {
+
+    private final AbstractTileSourceLayer<?> layer;
+
+    /**
+     * Constructs a new {@code ShowTileBordersAction}.
+     * @param layer imagery layer
+     */
+    public ShowTileBordersAction(AbstractTileSourceLayer<?> layer) {
+        super(tr("Show tile borders"));
+        this.layer = layer;
+    }
+
+    @Override
+    public void actionPerformed(ActionEvent ae) {
+        TileSourceDisplaySettings settings = layer.getDisplaySettings();
+        boolean show = !settings.isShowTileBorders();
+        settings.setShowTileBorders(show);
+        // remember the choice, so that it also applies to layers created later and after a restart. Only the
+        // action does this, not the setter, so that loading a session does not overwrite the preference.
+        TileSourceDisplaySettings.PROP_SHOW_TILE_BORDERS.put(show);
+    }
+
+    @Override
+    public Component createMenuComponent() {
+        JCheckBoxMenuItem item = new JCheckBoxMenuItem(this);
+        item.setSelected(layer.getDisplaySettings().isShowTileBorders());
+        return item;
+    }
+
+    @Override
+    public boolean supportLayers(List<Layer> layers) {
+        return AbstractTileSourceLayer.actionSupportLayers(layers);
+    }
+}
Index: trunk/src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java	(revision 19639)
+++ trunk/src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java	(revision 19640)
@@ -46,4 +46,6 @@
     private static final String SHOW_ERRORS = "show-errors";
 
+    private static final String SHOW_TILE_BORDERS = "show-tile-borders";
+
     private static final String DISPLACEMENT = "displacement";
 
@@ -59,4 +61,12 @@
      */
     public static final BooleanProperty PROP_AUTO_ZOOM = new BooleanProperty(PREFERENCE_PREFIX + ".default_autozoom", true);
+
+    /**
+     * The default tile borders property, remembered whenever the user toggles them, so that the choice also
+     * applies to layers created later and after a restart
+     * @since 19640
+     */
+    public static final BooleanProperty PROP_SHOW_TILE_BORDERS
+            = new BooleanProperty(PREFERENCE_PREFIX + ".default_showtileborders", false);
 
 
@@ -67,4 +77,6 @@
     /** if layer should show errors on tiles */
     private boolean showErrors;
+    /** if layer should draw a border around each tile */
+    private boolean showTileBorders;
 
     private OffsetBookmark previousOffsetBookmark;
@@ -97,13 +109,13 @@
         autoLoad = getProperty(prefixes, "default_autoload", PROP_AUTO_LOAD.getDefaultValue());
         showErrors = getProperty(prefixes, "default_showerrors", Boolean.TRUE);
+        showTileBorders = getProperty(prefixes, "default_showtileborders", PROP_SHOW_TILE_BORDERS.getDefaultValue());
     }
 
     private static boolean getProperty(String[] prefixes, String name, Boolean def) {
-        // iterate through all values to force the preferences to receive the default value.
-        // we only support a default value of true.
-        boolean value = true;
+        // iterate through all values to force the preferences to receive the default value
+        boolean value = def;
         for (String p : prefixes) {
             String key = p + "." + name;
-            boolean currentValue = Config.getPref().getBoolean(key, true);
+            boolean currentValue = Config.getPref().getBoolean(key, def);
             if (!Config.getPref().get(key, def.toString()).isEmpty()) {
                 value = currentValue;
@@ -168,4 +180,24 @@
         this.showErrors = showErrors;
         fireSettingsChange(SHOW_ERRORS);
+    }
+
+    /**
+     * If the layer should draw a thin border around each tile.
+     * @return <code>true</code> to draw tile borders.
+     * @since 19640
+     */
+    public boolean isShowTileBorders() {
+        return showTileBorders;
+    }
+
+    /**
+     * Sets the show tile borders property. Fires a change event.
+     * @param showTileBorders {@code true} if the layer should draw a thin border around each tile
+     * @see #isShowTileBorders()
+     * @since 19640
+     */
+    public void setShowTileBorders(boolean showTileBorders) {
+        this.showTileBorders = showTileBorders;
+        fireSettingsChange(SHOW_TILE_BORDERS);
     }
 
@@ -290,4 +322,5 @@
         data.put(AUTO_ZOOM, Boolean.toString(autoZoom));
         data.put(SHOW_ERRORS, Boolean.toString(showErrors));
+        data.put(SHOW_TILE_BORDERS, Boolean.toString(showTileBorders));
         return data;
     }
@@ -317,4 +350,9 @@
                 setShowErrors(Boolean.parseBoolean(doShowErrors));
             }
+
+            String doShowTileBorders = data.get(SHOW_TILE_BORDERS);
+            if (doShowTileBorders != null) {
+                setShowTileBorders(Boolean.parseBoolean(doShowTileBorders));
+            }
         } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException e) {
             throw BugReport.intercept(e).put("data", data);
@@ -324,5 +362,5 @@
     @Override
     public int hashCode() {
-        return Objects.hash(autoLoad, autoZoom, showErrors);
+        return Objects.hash(autoLoad, autoZoom, showErrors, showTileBorders);
     }
 
@@ -336,5 +374,6 @@
         return autoLoad == other.autoLoad
             && autoZoom == other.autoZoom
-            && showErrors == other.showErrors;
+            && showErrors == other.showErrors
+            && showTileBorders == other.showTileBorders;
     }
 
@@ -342,5 +381,5 @@
     public String toString() {
         return "TileSourceDisplaySettings [autoZoom=" + autoZoom + ", autoLoad=" + autoLoad + ", showErrors="
-                + showErrors + ']';
+                + showErrors + ", showTileBorders=" + showTileBorders + ']';
     }
 
Index: trunk/src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java	(revision 19639)
+++ trunk/src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java	(revision 19640)
@@ -52,4 +52,5 @@
 import org.openstreetmap.josm.gui.preferences.display.DrawingPreference;
 import org.openstreetmap.josm.gui.preferences.display.GPXPreference;
+import org.openstreetmap.josm.gui.preferences.display.GridPreference;
 import org.openstreetmap.josm.gui.preferences.display.LafPreference;
 import org.openstreetmap.josm.gui.preferences.display.LanguagePreference;
@@ -611,4 +612,5 @@
         SETTINGS_FACTORIES.add(new ProxyPreference.Factory());
         SETTINGS_FACTORIES.add(new ProjectionPreference.Factory());
+        SETTINGS_FACTORIES.add(new GridPreference.Factory());
         SETTINGS_FACTORIES.add(new MapPaintPreference.Factory());
         SETTINGS_FACTORIES.add(new TaggingPresetPreference.Factory());
Index: trunk/src/org/openstreetmap/josm/gui/preferences/display/GridPreference.java
===================================================================
--- trunk/src/org/openstreetmap/josm/gui/preferences/display/GridPreference.java	(revision 19640)
+++ trunk/src/org/openstreetmap/josm/gui/preferences/display/GridPreference.java	(revision 19640)
@@ -0,0 +1,216 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui.preferences.display;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+import java.awt.Component;
+import java.awt.GridBagLayout;
+import java.text.DecimalFormat;
+import java.text.DecimalFormatSymbols;
+import java.text.ParseException;
+import java.util.Locale;
+import java.util.function.Function;
+
+import javax.swing.DefaultListCellRenderer;
+import javax.swing.JCheckBox;
+import javax.swing.JLabel;
+import javax.swing.JList;
+import javax.swing.JPanel;
+
+import org.openstreetmap.josm.data.preferences.DoubleProperty;
+import org.openstreetmap.josm.gui.draw.BlendComposite;
+import org.openstreetmap.josm.gui.help.HelpUtil;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable;
+import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
+import org.openstreetmap.josm.gui.preferences.DefaultTabPreferenceSetting;
+import org.openstreetmap.josm.gui.preferences.PreferenceSetting;
+import org.openstreetmap.josm.gui.preferences.PreferenceSettingFactory;
+import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane;
+import org.openstreetmap.josm.gui.widgets.JosmComboBox;
+import org.openstreetmap.josm.gui.widgets.JosmTextField;
+import org.openstreetmap.josm.tools.GBC;
+import org.openstreetmap.josm.tools.Logging;
+
+/**
+ * Settings of the grid drawn over the map, see {@link MapGridPaintable}.
+ * @since 19640
+ */
+public class GridPreference extends DefaultTabPreferenceSetting {
+
+    /**
+     * Factory used to create a new {@code GridPreference}.
+     */
+    public static class Factory implements PreferenceSettingFactory {
+        @Override
+        public PreferenceSetting createPreferenceSetting() {
+            return new GridPreference();
+        }
+    }
+
+    private static final DecimalFormat FORMAT = new DecimalFormat("0.#########", DecimalFormatSymbols.getInstance(Locale.ROOT));
+
+    private final JCheckBox enabled = new JCheckBox(tr("Show grid"));
+    private final JosmComboBox<GridType> type = new JosmComboBox<>(GridType.values());
+    private final JosmTextField spacingX = new JosmTextField(10);
+    private final JosmTextField spacingY = new JosmTextField(10);
+    private final JosmTextField rotation = new JosmTextField(10);
+    private final JosmTextField originX = new JosmTextField(10);
+    private final JosmTextField originY = new JosmTextField(10);
+    private final JosmTextField minPixelSpacing = new JosmTextField(10);
+    private final JosmTextField lineWidth = new JosmTextField(10);
+    private final JosmComboBox<BlendComposite.Mode> blendMode = new JosmComboBox<>(BlendComposite.Mode.values());
+    private final JLabel spacingXLabel = new JLabel();
+    private final JLabel spacingYLabel = new JLabel();
+    private final JLabel originXLabel = new JLabel();
+    private final JLabel originYLabel = new JLabel();
+
+    GridPreference() {
+        super("preferences/grid", tr("Grid"), tr("Settings of the grid drawn over the map."));
+    }
+
+    @Override
+    public void addGui(PreferenceTabbedPane gui) {
+        JPanel panel = new JPanel(new GridBagLayout());
+
+        enabled.setSelected(Boolean.TRUE.equals(MapGridPaintable.ENABLED.get()));
+        enabled.setToolTipText(tr("Draw a grid over the map (View menu: Show grid). "
+                + "The grid is a visual aid only, there is no snapping to it."));
+        type.setSelectedItem(MapGridPaintable.TYPE.get());
+        type.setRenderer(new TranslatedRenderer<>(t -> t == GridType.PROJECTED ? tr("Projected coordinates") : tr("Latitude/longitude")));
+        type.setToolTipText("<html>" + tr("Latitude/longitude: lines of constant latitude and longitude, spacing in degrees.<br>"
+                + "Projected: lines of constant east/north coordinate of the map projection, "
+                + "spacing in metres, optionally rotated.") + "</html>");
+        type.addActionListener(e -> updateLabels());
+        set(spacingX, MapGridPaintable.SPACING_X);
+        set(spacingY, MapGridPaintable.SPACING_Y);
+        set(rotation, MapGridPaintable.ROTATION);
+        rotation.setToolTipText(tr("Rotation of a projected grid in degrees, counter clockwise. Ignored for a latitude/longitude grid."));
+        set(originX, MapGridPaintable.ORIGIN_X);
+        set(originY, MapGridPaintable.ORIGIN_Y);
+        set(minPixelSpacing, MapGridPaintable.MIN_PIXEL_SPACING);
+        set(lineWidth, MapGridPaintable.LINE_WIDTH);
+        lineWidth.setToolTipText(tr("Width of the grid lines in pixels. The colors of the east and north lines are set in the Colors tab."));
+        minPixelSpacing.setToolTipText(tr("When the grid lines get closer than this on screen, the spacing is multiplied by 10 "
+                + "so that the grid stays readable when zooming out."));
+        blendMode.setSelectedItem(MapGridPaintable.BLEND_MODE.get());
+        blendMode.setRenderer(new TranslatedRenderer<>(GridPreference::blendModeName));
+        blendMode.setToolTipText(tr("How the lines are combined with the map: normal transparency, multiply (darkens), "
+                + "burn, hard light, difference (visible on any background) or divide. The colors are set in the Colors tab."));
+        updateLabels();
+
+        panel.add(enabled, GBC.eol().insets(0, 0, 0, 10));
+        panel.add(new JLabel(tr("Grid type")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(type, GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 5));
+        panel.add(spacingXLabel, GBC.std().insets(5, 0, 5, 5));
+        panel.add(spacingX, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(spacingYLabel, GBC.std().insets(5, 0, 5, 5));
+        panel.add(spacingY, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(new JLabel(tr("Rotation (degrees)")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(rotation, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(originXLabel, GBC.std().insets(5, 0, 5, 5));
+        panel.add(originX, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(originYLabel, GBC.std().insets(5, 0, 5, 5));
+        panel.add(originY, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(new JLabel(tr("Minimum line distance on screen (pixels)")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(minPixelSpacing, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(new JLabel(tr("Line width (pixels)")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(lineWidth, GBC.eol().insets(0, 0, 0, 5));
+        panel.add(new JLabel(tr("Blend mode")), GBC.std().insets(5, 0, 5, 5));
+        panel.add(blendMode, GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 5));
+        panel.add(GBC.glue(0, 0), GBC.eol().fill(GBC.BOTH));
+
+        createPreferenceTabWithScrollPane(gui, panel);
+    }
+
+    private static String blendModeName(BlendComposite.Mode mode) {
+        switch (mode) {
+        case MULTIPLY:
+            return tr("Multiply");
+        case BURN:
+            return tr("Burn");
+        case HARD_LIGHT:
+            return tr("Hard light");
+        case DIFFERENCE:
+            return tr("Difference");
+        case DIVIDE:
+            return tr("Divide");
+        case NORMAL:
+        default:
+            return tr("Normal");
+        }
+    }
+
+    /** Renders enum values with a translated name */
+    private static final class TranslatedRenderer<T> extends DefaultListCellRenderer {
+        private final Function<T, String> name;
+
+        TranslatedRenderer(Function<T, String> name) {
+            this.name = name;
+        }
+
+        @Override
+        @SuppressWarnings("unchecked")
+        public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
+            String text = value == null ? "" : name.apply((T) value);
+            return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus);
+        }
+    }
+
+    private void updateLabels() {
+        boolean latlon = type.getSelectedItem() != GridType.PROJECTED;
+        spacingXLabel.setText(latlon ? tr("Longitude spacing (degrees)") : tr("East spacing (metres)"));
+        spacingYLabel.setText(latlon ? tr("Latitude spacing (degrees)") : tr("North spacing (metres)"));
+        String spacingTip = latlon
+                ? tr("Distance between the grid lines in degrees.")
+                : tr("Distance between the grid lines as a true distance in metres (as measured by the parallel way tool "
+                        + "and the status bar), measured at the grid origin. Place the origin in the area of interest.");
+        spacingX.setToolTipText(spacingTip);
+        spacingY.setToolTipText(spacingTip);
+        originXLabel.setText(latlon ? tr("Origin longitude") : tr("Origin east"));
+        originYLabel.setText(latlon ? tr("Origin latitude") : tr("Origin north"));
+        rotation.setEnabled(!latlon);
+    }
+
+    private static void set(JosmTextField field, DoubleProperty property) {
+        field.setText(FORMAT.format(property.get()));
+    }
+
+    private static void save(JosmTextField field, DoubleProperty property, boolean positive) {
+        try {
+            double value = FORMAT.parse(field.getText().trim()).doubleValue();
+            if (positive && !(value > 0)) {
+                Logging.warn("Ignoring invalid grid setting {0}: {1}", property.getKey(), field.getText());
+                return;
+            }
+            property.put(value);
+        } catch (ParseException e) {
+            Logging.warn("Ignoring invalid grid setting {0}: {1}", property.getKey(), field.getText());
+            Logging.trace(e);
+        }
+    }
+
+    @Override
+    public boolean ok() {
+        MapGridPaintable.ENABLED.put(enabled.isSelected());
+        MapGridPaintable.TYPE.put((GridType) type.getSelectedItem());
+        save(spacingX, MapGridPaintable.SPACING_X, true);
+        save(spacingY, MapGridPaintable.SPACING_Y, true);
+        save(rotation, MapGridPaintable.ROTATION, false);
+        save(originX, MapGridPaintable.ORIGIN_X, false);
+        save(originY, MapGridPaintable.ORIGIN_Y, false);
+        save(minPixelSpacing, MapGridPaintable.MIN_PIXEL_SPACING, true);
+        save(lineWidth, MapGridPaintable.LINE_WIDTH, true);
+        MapGridPaintable.BLEND_MODE.put((BlendComposite.Mode) blendMode.getSelectedItem());
+        return false;
+    }
+
+    @Override
+    public boolean isExpert() {
+        return false;
+    }
+
+    @Override
+    public String getHelpContext() {
+        return HelpUtil.ht("/Preferences/Grid");
+    }
+}
