Ticket #8464: josm-map-grid.patch

File josm-map-grid.patch, 107.1 KB (added by wangi, 20 hours ago)

Patch

  • new file resources/images/grid.svg

    diff --git resources/images/grid.svg resources/images/grid.svg
    new file mode 100644
    index 0000000000..a68166e9cc
    - +  
     1<?xml version="1.0" encoding="UTF-8"?>
     2<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
     3  <rect x="2.5" y="2.5" width="19" height="19" rx="1.5" fill="#f4f4f4" stroke="#606060"/>
     4  <path d="M9 2.5v19M15.5 2.5v19M2.5 9h19M2.5 15.5h19" fill="none" stroke="#3d7ac2"/>
     5</svg>
  • new file resources/images/gridorigin.svg

    diff --git resources/images/gridorigin.svg resources/images/gridorigin.svg
    new file mode 100644
    index 0000000000..f92f0fb38e
    - +  
     1<?xml version="1.0" encoding="UTF-8"?>
     2<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
     3  <rect x="2.5" y="2.5" width="19" height="19" rx="1.5" fill="#f4f4f4" stroke="#606060"/>
     4  <path d="M12 2.5v19M2.5 12h19" fill="none" stroke="#3d7ac2"/>
     5  <rect x="9" y="9" width="6" height="6" fill="#ffffff" stroke="#df421e" stroke-width="2"/><!-- origin at a grid crossing -->
     6</svg>
  • new file resources/images/gridrotate.svg

    diff --git resources/images/gridrotate.svg resources/images/gridrotate.svg
    new file mode 100644
    index 0000000000..527ad84175
    - +  
     1<?xml version="1.0" encoding="UTF-8"?>
     2<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
     3  <g transform="rotate(-30 12 12)" fill="none" stroke="#3d7ac2" stroke-width="1.5"><!-- grid turned to the way below -->
     4    <rect x="4.5" y="4.5" width="15" height="15"/>
     5    <path d="M12 4.5v15"/>
     6  </g>
     7  <path d="M2.4 17.7 21.6 6.6" fill="none" stroke="#df421e" stroke-width="2.5"/><!-- the selected way -->
     8</svg>
  • new file resources/images/preferences/grid.svg

    diff --git resources/images/preferences/grid.svg resources/images/preferences/grid.svg
    new file mode 100644
    index 0000000000..9107df1f5d
    - +  
     1<?xml version="1.0" encoding="UTF-8"?>
     2<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
     3  <rect x="4" y="4" width="40" height="40" rx="3" fill="#f4f4f4" stroke="#606060" stroke-width="2"/>
     4  <path d="M17.3 4v40M30.7 4v40M4 17.3h40M4 30.7h40" fill="none" stroke="#3d7ac2" stroke-width="2"/>
     5</svg>
  • new file src/org/openstreetmap/josm/actions/AlignGridRotationAction.java

    diff --git src/org/openstreetmap/josm/actions/AlignGridRotationAction.java src/org/openstreetmap/josm/actions/AlignGridRotationAction.java
    new file mode 100644
    index 0000000000..b3b0eb616c
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.actions;
     3
     4import static org.openstreetmap.josm.tools.I18n.tr;
     5
     6import java.awt.event.ActionEvent;
     7import java.util.Collection;
     8import java.util.Iterator;
     9
     10import org.openstreetmap.josm.data.coor.EastNorth;
     11import org.openstreetmap.josm.data.osm.DataSet;
     12import org.openstreetmap.josm.data.osm.Node;
     13import org.openstreetmap.josm.data.osm.OsmPrimitive;
     14import org.openstreetmap.josm.data.osm.Way;
     15import org.openstreetmap.josm.gui.MainApplication;
     16import org.openstreetmap.josm.gui.layer.MapGridPaintable;
     17import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
     18
     19/**
     20 * Rotates the grid drawn over the map so that its lines run parallel to the current selection: either a single way
     21 * (the direction from its first to its last node) or exactly two nodes. Switches the grid to projected coordinates,
     22 * since a latitude/longitude grid cannot be rotated, and enables it.
     23 * @see MapGridPaintable
     24 * @see SetGridOriginAction
     25 * @since xxx
     26 */
     27public class AlignGridRotationAction extends JosmAction {
     28
     29    /**
     30     * Constructs a new {@code AlignGridRotationAction}.
     31     */
     32    public AlignGridRotationAction() {
     33        super(tr("Align rotation to selection"), "gridrotate",
     34                tr("Rotate the grid so that its lines are parallel to the selected way "
     35                        + "(first to last node) or to the line between the two selected nodes. Switches to a projected grid."),
     36                null, false);
     37    }
     38
     39    /**
     40     * Determines the direction given by the selection: a single way with at least two nodes, or exactly two nodes.
     41     * @param ds the data set, may be null
     42     * @return start and end point, or {@code null} if the selection does not define a direction
     43     */
     44    static EastNorth[] getSelectedDirection(DataSet ds) {
     45        if (ds == null) {
     46            return null;
     47        }
     48        Collection<Way> ways = ds.getSelectedWays();
     49        Collection<Node> nodes = ds.getSelectedNodes();
     50        Node a = null;
     51        Node b = null;
     52        if (ways.size() == 1 && nodes.isEmpty()) {
     53            Way w = ways.iterator().next();
     54            if (w.getNodesCount() >= 2) {
     55                a = w.firstNode();
     56                b = w.isClosed() ? w.getNode(1) : w.lastNode();
     57            }
     58        } else if (nodes.size() == 2 && ways.isEmpty()) {
     59            Iterator<Node> it = nodes.iterator();
     60            a = it.next();
     61            b = it.next();
     62        }
     63        if (a == null || b == null || !a.isLatLonKnown() || !b.isLatLonKnown()) {
     64            return null;
     65        }
     66        EastNorth[] result = {a.getEastNorth(), b.getEastNorth()};
     67        return result[0].equalsEpsilon(result[1], 1e-9) ? null : result;
     68    }
     69
     70    /**
     71     * Computes the grid rotation (counter clockwise, in degrees, in the range [0, 90)) for which a grid line is
     72     * parallel to the given direction.
     73     * @param from start point
     74     * @param to end point
     75     * @return the rotation in degrees
     76     */
     77    public static double rotationOf(EastNorth from, EastNorth to) {
     78        double angle = Math.toDegrees(Math.atan2(to.north() - from.north(), to.east() - from.east()));
     79        angle %= 90;
     80        if (angle < 0) {
     81            angle += 90;
     82        }
     83        return angle >= 90 - 1e-9 ? 0 : angle;
     84    }
     85
     86    @Override
     87    protected void updateEnabledState() {
     88        setEnabled(getSelectedDirection(getLayerManager().getEditDataSet()) != null);
     89    }
     90
     91    @Override
     92    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
     93        updateEnabledState();
     94    }
     95
     96    @Override
     97    public void actionPerformed(ActionEvent e) {
     98        EastNorth[] direction = getSelectedDirection(getLayerManager().getEditDataSet());
     99        if (direction == null) {
     100            return;
     101        }
     102        MapGridPaintable.ROTATION.put(rotationOf(direction[0], direction[1]));
     103        MapGridPaintable.TYPE.put(GridType.PROJECTED);
     104        MapGridPaintable.ENABLED.put(true);
     105        if (MainApplication.isDisplayingMapView()) {
     106            MainApplication.getMap().mapView.repaint();
     107        }
     108    }
     109}
  • new file src/org/openstreetmap/josm/actions/SetGridOriginAction.java

    diff --git src/org/openstreetmap/josm/actions/SetGridOriginAction.java src/org/openstreetmap/josm/actions/SetGridOriginAction.java
    new file mode 100644
    index 0000000000..d45b72ce5a
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.actions;
     3
     4import static org.openstreetmap.josm.tools.I18n.tr;
     5
     6import java.awt.event.ActionEvent;
     7import java.util.Collection;
     8
     9import org.openstreetmap.josm.data.coor.EastNorth;
     10import org.openstreetmap.josm.data.coor.LatLon;
     11import org.openstreetmap.josm.data.osm.DataSet;
     12import org.openstreetmap.josm.data.osm.Node;
     13import org.openstreetmap.josm.data.osm.OsmPrimitive;
     14import org.openstreetmap.josm.data.osm.visitor.AllNodesVisitor;
     15import org.openstreetmap.josm.data.projection.ProjectionRegistry;
     16import org.openstreetmap.josm.gui.MainApplication;
     17import org.openstreetmap.josm.gui.layer.MapGridPaintable;
     18import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
     19import org.openstreetmap.josm.tools.Logging;
     20
     21/**
     22 * Moves the origin of the grid drawn over the map to the current selection, so that grid lines pass through it,
     23 * and enables the grid. The origin is the position of the selected node, or the centroid (arithmetic mean
     24 * position) of all nodes reachable from the current selection (nodes of selected ways, node members of selected
     25 * relations) if more than one node is involved.
     26 * @see MapGridPaintable
     27 * @see AlignGridRotationAction
     28 * @since xxx
     29 */
     30public class SetGridOriginAction extends JosmAction {
     31
     32    /**
     33     * Constructs a new {@code SetGridOriginAction}.
     34     */
     35    public SetGridOriginAction() {
     36        super(tr("Set origin to selection"), "gridorigin",
     37                tr("Move the grid so that a grid line passes through the selected node, "
     38                        + "or through the centroid of the current selection, and show the grid."),
     39                null, false);
     40    }
     41
     42    @Override
     43    protected void updateEnabledState() {
     44        setEnabled(getCentroid(getLayerManager().getEditDataSet()) != null);
     45    }
     46
     47    @Override
     48    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
     49        updateEnabledState();
     50    }
     51
     52    @Override
     53    public void actionPerformed(ActionEvent e) {
     54        EastNorth centroid = getCentroid(getLayerManager().getEditDataSet());
     55        if (centroid == null) {
     56            return;
     57        }
     58        setOrigin(centroid);
     59        if (MainApplication.isDisplayingMapView()) {
     60            MainApplication.getMap().mapView.repaint();
     61        }
     62    }
     63
     64    /**
     65     * Computes the centroid of the current selection: the (arithmetic mean) position of every node reachable from
     66     * the selected primitives (the selected nodes themselves, the nodes of selected ways, and the node members of
     67     * selected relations). For a single selected node this is simply that node's position.
     68     * @param ds the data set, may be {@code null}
     69     * @return the centroid, or {@code null} if the selection contains no node with known coordinates
     70     */
     71    static EastNorth getCentroid(DataSet ds) {
     72        if (ds == null) {
     73            return null;
     74        }
     75        double sumEast = 0;
     76        double sumNorth = 0;
     77        int count = 0;
     78        for (Node n : AllNodesVisitor.getAllNodes(ds.getSelected())) {
     79            if (n.isLatLonKnown()) {
     80                EastNorth en = n.getEastNorth();
     81                sumEast += en.east();
     82                sumNorth += en.north();
     83                count++;
     84            }
     85        }
     86        return count == 0 ? null : new EastNorth(sumEast / count, sumNorth / count);
     87    }
     88
     89    /**
     90     * Sets the grid origin (in the coordinates of the current grid type) and enables the grid.
     91     * @param position the new grid origin, in projected coordinates
     92     */
     93    public static void setOrigin(EastNorth position) {
     94        if (MapGridPaintable.TYPE.get() == GridType.PROJECTED) {
     95            MapGridPaintable.ORIGIN_X.put(position.east());
     96            MapGridPaintable.ORIGIN_Y.put(position.north());
     97        } else {
     98            final LatLon ll;
     99            try {
     100                ll = ProjectionRegistry.getProjection().eastNorth2latlon(position);
     101            } catch (IllegalArgumentException e) {
     102                // the position is outside the domain of the projection, leave the grid as it is
     103                Logging.warn("Cannot use {0} as grid origin: {1}", position, e.getMessage());
     104                Logging.trace(e);
     105                return;
     106            }
     107            MapGridPaintable.ORIGIN_X.put(ll.lon());
     108            MapGridPaintable.ORIGIN_Y.put(ll.lat());
     109        }
     110        MapGridPaintable.ENABLED.put(true);
     111    }
     112}
  • new file src/org/openstreetmap/josm/actions/ShowGridAction.java

    diff --git src/org/openstreetmap/josm/actions/ShowGridAction.java src/org/openstreetmap/josm/actions/ShowGridAction.java
    new file mode 100644
    index 0000000000..4f1343ff37
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.actions;
     3
     4import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
     5import static org.openstreetmap.josm.tools.I18n.tr;
     6
     7import java.awt.event.ActionEvent;
     8
     9import org.openstreetmap.josm.gui.MainApplication;
     10import org.openstreetmap.josm.gui.layer.MapGridPaintable;
     11import org.openstreetmap.josm.tools.ImageProvider;
     12
     13/**
     14 * This action toggles the display of the grid over the map view.
     15 * @see MapGridPaintable
     16 * @since xxx
     17 */
     18public class ShowGridAction extends PreferenceToggleAction {
     19
     20    /**
     21     * Constructs a new {@link ShowGridAction}.
     22     */
     23    public ShowGridAction() {
     24        super(tr("Show"),
     25                new ImageProvider("grid"),
     26                tr("Enable/disable the grid drawn over the map. Its spacing and orientation are set in the display preferences."),
     27                MapGridPaintable.ENABLED
     28        );
     29        setHelpId(ht("/MapView#Grid"));
     30    }
     31
     32    @Override
     33    protected boolean listenToSelectionChange() {
     34        return false;
     35    }
     36
     37    @Override
     38    protected void updateEnabledState() {
     39        setEnabled(MainApplication.isDisplayingMapView());
     40    }
     41
     42    @Override
     43    public void actionPerformed(ActionEvent e) {
     44        super.actionPerformed(e);
     45        if (MainApplication.isDisplayingMapView()) {
     46            MainApplication.getMap().mapView.repaint();
     47        }
     48    }
     49}
  • src/org/openstreetmap/josm/gui/MainMenu.java

    diff --git src/org/openstreetmap/josm/gui/MainMenu.java src/org/openstreetmap/josm/gui/MainMenu.java
    index 3a089782ba..eeb0d37368 100644
    import javax.swing.event.MenuListener;  
    3030
    3131import org.openstreetmap.josm.actions.AboutAction;
    3232import org.openstreetmap.josm.actions.AddNodeAction;
     33import org.openstreetmap.josm.actions.AlignGridRotationAction;
    3334import org.openstreetmap.josm.actions.AlignInCircleAction;
    3435import org.openstreetmap.josm.actions.AlignInLineAction;
    3536import org.openstreetmap.josm.actions.AutoScaleAction;
    import org.openstreetmap.josm.actions.SelectNonBranchingWaySequencesAction;  
    100101import org.openstreetmap.josm.actions.SelectSharedChildObjectsAction;
    101102import org.openstreetmap.josm.actions.SessionSaveAction;
    102103import org.openstreetmap.josm.actions.SessionSaveAsAction;
     104import org.openstreetmap.josm.actions.SetGridOriginAction;
     105import org.openstreetmap.josm.actions.ShowGridAction;
    103106import org.openstreetmap.josm.actions.ShowStatusReportAction;
    104107import org.openstreetmap.josm.actions.SimplifyWayAction;
    105108import org.openstreetmap.josm.actions.SplitWayAction;
    import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent;  
    135138import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener;
    136139import org.openstreetmap.josm.gui.layer.geoimage.WikimediaCommonsLoader.WikimediaCommonsLoadImagesAction;
    137140import org.openstreetmap.josm.gui.mappaint.MapPaintMenu;
     141import org.openstreetmap.josm.gui.preferences.display.GridPreference;
    138142import org.openstreetmap.josm.gui.preferences.imagery.ImageryPreference;
    139143import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSearchPrimitiveDialog;
    140144import org.openstreetmap.josm.spi.preferences.Config;
    public class MainMenu extends JMenuBar {  
    256260    public final TiledRenderToggleAction tiledRenderToggleAction = new TiledRenderToggleAction();
    257261    /** View / Hatch area outside download */
    258262    public final DrawBoundariesOfDownloadedDataAction drawBoundariesOfDownloadedDataAction = new DrawBoundariesOfDownloadedDataAction();
     263    /** View / Grid submenu: the grid drawn over the map and its placement */
     264    public final JMenu gridMenu = new JMenu(tr("Grid"));
     265    /** View / Grid / Show */
     266    public final ShowGridAction showGridAction = new ShowGridAction();
     267    /** View / Grid / Set origin to selection */
     268    public final SetGridOriginAction setGridOriginAction = new SetGridOriginAction();
     269    /** View / Grid / Align rotation to selection */
     270    public final AlignGridRotationAction alignGridRotationAction = new AlignGridRotationAction();
    259271    /** View / Advanced info */
    260272    public final InfoAction info = new InfoAction();
    261273    /** View / Advanced info (web) */
    public class MainMenu extends JMenuBar {  
    815827        final JCheckBoxMenuItem hatchAreaOutsideDownloadMenuItem = drawBoundariesOfDownloadedDataAction.getCheckbox();
    816828        viewMenu.add(hatchAreaOutsideDownloadMenuItem);
    817829        ExpertToggleAction.addVisibilitySwitcher(hatchAreaOutsideDownloadMenuItem);
     830        // -- Grid submenu
     831        gridMenu.setIcon(ImageProvider.get("grid", ImageProvider.ImageSizes.MENU));
     832        gridMenu.add(showGridAction.getCheckbox());
     833        add(gridMenu, setGridOriginAction);
     834        add(gridMenu, alignGridRotationAction);
     835        gridMenu.addSeparator();
     836        add(gridMenu, PreferencesAction.forPreferenceTab(tr("Grid preferences..."),
     837                tr("Click to open the grid tab in the preferences"), GridPreference.class));
     838        viewMenu.add(gridMenu);
    818839
    819840        viewMenu.add(new MapPaintMenu());
    820841        viewMenu.addSeparator();
  • src/org/openstreetmap/josm/gui/MapFrame.java

    diff --git src/org/openstreetmap/josm/gui/MapFrame.java src/org/openstreetmap/josm/gui/MapFrame.java
    index f3e81c9953..341371d0c4 100644
    import org.openstreetmap.josm.gui.dialogs.UserListDialog;  
    7373import org.openstreetmap.josm.gui.dialogs.ValidatorDialog;
    7474import org.openstreetmap.josm.gui.dialogs.properties.PropertiesDialog;
    7575import org.openstreetmap.josm.gui.layer.Layer;
     76import org.openstreetmap.josm.gui.layer.MapGridPaintable;
    7677import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
    7778import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
    7879import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
    public class MapFrame extends JPanel implements Destroyable, ActiveLayerChangeLi  
    122123     * The view control displayed.
    123124     */
    124125    public final MapView mapView;
     126    /** The grid drawn over the map view, see {@link MapGridPaintable} */
     127    private final MapGridPaintable gridOverlay = new MapGridPaintable();
    125128
    126129    /**
    127130     * This object allows to detect key press and release events
    public class MapFrame extends JPanel implements Destroyable, ActiveLayerChangeLi  
    204207        setLayout(new BorderLayout());
    205208
    206209        mapView = new MapView(MainApplication.getLayerManager(), viewportData);
     210        mapView.addTemporaryLayer(gridOverlay);
    207211
    208212        splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, true);
    209213
    public class MapFrame extends JPanel implements Destroyable, ActiveLayerChangeLi  
    366370        toolBarToggle.removeAll();
    367371
    368372        statusLine.destroy();
     373        mapView.removeTemporaryLayer(gridOverlay);
     374        gridOverlay.destroy();
    369375        mapView.destroy();
    370376        keyDetector.unregister();
    371377
  • new file src/org/openstreetmap/josm/gui/draw/BlendComposite.java

    diff --git src/org/openstreetmap/josm/gui/draw/BlendComposite.java src/org/openstreetmap/josm/gui/draw/BlendComposite.java
    new file mode 100644
    index 0000000000..e00e14ca3d
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.draw;
     3
     4import java.awt.Composite;
     5import java.awt.CompositeContext;
     6import java.awt.RenderingHints;
     7import java.awt.image.ColorModel;
     8import java.awt.image.Raster;
     9import java.awt.image.WritableRaster;
     10import java.util.EnumMap;
     11import java.util.Map;
     12
     13/**
     14 * A {@link Composite} implementing the common "blend modes" of image editors (multiply, burn, hard light,
     15 * difference, divide), which the standard {@link java.awt.AlphaComposite} does not provide.
     16 * <p>
     17 * The source alpha (including the coverage produced by antialiasing) controls how strongly the blended color
     18 * replaces the destination, following the "source over" rule of the W3C compositing model, so that a
     19 * translucent destination is handled correctly as well. It works on any color model, but is implemented per
     20 * pixel and thus only meant for drawing thin shapes such as grid lines. It must be used on
     21 * {@link java.awt.image.BufferedImage} backed graphics, since hardware accelerated pipelines do not support
     22 * custom composites.
     23 * @since xxx
     24 */
     25public final class BlendComposite implements Composite {
     26
     27    /**
     28     * The supported blend modes.
     29     */
     30    public enum Mode {
     31        /** Normal alpha blending (like {@link java.awt.AlphaComposite#SrcOver}) */
     32        NORMAL,
     33        /** Multiplies source and destination: darkens, white is neutral */
     34        MULTIPLY,
     35        /** Color burn: darkens and increases contrast, white is neutral */
     36        BURN,
     37        /** Hard light: multiplies for dark sources, screens for light sources; strong contrast */
     38        HARD_LIGHT,
     39        /** Absolute difference between source and destination: always visible on any background */
     40        DIFFERENCE,
     41        /** Divides destination by source: lightens, white is neutral, dark sources give bright lines */
     42        DIVIDE
     43    }
     44
     45    private static final Map<Mode, BlendComposite> INSTANCES = new EnumMap<>(Mode.class);
     46
     47    private final Mode mode;
     48
     49    private BlendComposite(Mode mode) {
     50        this.mode = mode;
     51    }
     52
     53    /**
     54     * Returns the composite for the given mode.
     55     * @param mode the blend mode
     56     * @return the composite
     57     */
     58    public static synchronized BlendComposite getInstance(Mode mode) {
     59        return INSTANCES.computeIfAbsent(mode, BlendComposite::new);
     60    }
     61
     62    /**
     63     * Returns the blend mode of this composite.
     64     * @return the blend mode
     65     */
     66    public Mode getMode() {
     67        return mode;
     68    }
     69
     70    @Override
     71    public CompositeContext createContext(ColorModel srcColorModel, ColorModel dstColorModel, RenderingHints hints) {
     72        return new BlendContext(mode, srcColorModel, dstColorModel);
     73    }
     74
     75    /**
     76     * Blends one color channel.
     77     * @param mode blend mode
     78     * @param s source channel value (0-255)
     79     * @param d destination channel value (0-255)
     80     * @return blended value (0-255)
     81     */
     82    static int blend(Mode mode, int s, int d) {
     83        switch (mode) {
     84        case MULTIPLY:
     85            return s * d / 255;
     86        case BURN:
     87            return s == 0 ? 0 : 255 - Math.min(255, (255 - d) * 255 / s);
     88        case HARD_LIGHT:
     89            return s < 128 ? 2 * s * d / 255 : 255 - (255 - d) * (510 - 2 * s) / 255;
     90        case DIFFERENCE:
     91            return Math.abs(s - d);
     92        case DIVIDE:
     93            return s == 0 ? 255 : Math.min(255, d * 255 / s);
     94        case NORMAL:
     95        default:
     96            return s;
     97        }
     98    }
     99
     100    /**
     101     * Composes one pixel: blends the source color with the destination and combines the two with the
     102     * "source over" rule, so that both the source alpha (including the coverage produced by antialiasing)
     103     * and a translucent destination are handled correctly.
     104     * @param mode blend mode
     105     * @param s source color (ARGB, not premultiplied)
     106     * @param d destination color (ARGB, not premultiplied)
     107     * @return the resulting color (ARGB, not premultiplied)
     108     */
     109    static int composePixel(Mode mode, int s, int d) {
     110        int sa = s >>> 24;
     111        if (sa == 0) {
     112            return d;
     113        }
     114        int da = d >>> 24;
     115        if (da == 0xff) {
     116            // opaque destination (the map view): the result is the destination moved towards the blended color
     117            return 0xff000000
     118                    | (mix(blend(mode, (s >> 16) & 0xff, (d >> 16) & 0xff), (d >> 16) & 0xff, sa) << 16)
     119                    | (mix(blend(mode, (s >> 8) & 0xff, (d >> 8) & 0xff), (d >> 8) & 0xff, sa) << 8)
     120                    | mix(blend(mode, s & 0xff, d & 0xff), d & 0xff, sa);
     121        }
     122        int a = sa + da * (0xff - sa) / 0xff;
     123        if (a == 0) {
     124            return 0;
     125        }
     126        return (a << 24)
     127                | (composeChannel(mode, (s >> 16) & 0xff, (d >> 16) & 0xff, sa, da, a) << 16)
     128                | (composeChannel(mode, (s >> 8) & 0xff, (d >> 8) & 0xff, sa, da, a) << 8)
     129                | composeChannel(mode, s & 0xff, d & 0xff, sa, da, a);
     130    }
     131
     132    /**
     133     * Composes one color channel of a translucent destination, see
     134     * <a href="https://www.w3.org/TR/compositing-1/#blending">the W3C compositing model</a>:
     135     * {@code co = as*(1-ab)*Cs + as*ab*B(Cb,Cs) + (1-as)*ab*Cb} and {@code Co = co/ao}.
     136     * @param mode blend mode
     137     * @param cs source channel value (0-255)
     138     * @param cb destination (backdrop) channel value (0-255)
     139     * @param sa source alpha (0-255)
     140     * @param da destination alpha (0-255)
     141     * @param a the resulting alpha (0-255), must not be 0
     142     * @return the resulting channel value (0-255)
     143     */
     144    private static int composeChannel(Mode mode, int cs, int cb, int sa, int da, int a) {
     145        int co = sa * (0xff - da) * cs + sa * da * blend(mode, cs, cb) + (0xff - sa) * da * cb;
     146        return co / (0xff * a);
     147    }
     148
     149    /** linear interpolation between d (alpha 0) and s (alpha 255) */
     150    private static int mix(int s, int d, int alpha) {
     151        return d + (s - d) * alpha / 0xff;
     152    }
     153
     154    private static final class BlendContext implements CompositeContext {
     155        private final Mode mode;
     156        private final ColorModel srcColorModel;
     157        private final ColorModel dstColorModel;
     158
     159        BlendContext(Mode mode, ColorModel srcColorModel, ColorModel dstColorModel) {
     160            this.mode = mode;
     161            this.srcColorModel = srcColorModel;
     162            this.dstColorModel = dstColorModel;
     163        }
     164
     165        @Override
     166        public void compose(Raster src, Raster dstIn, WritableRaster dstOut) {
     167            int w = Math.min(Math.min(src.getWidth(), dstIn.getWidth()), dstOut.getWidth());
     168            int h = Math.min(Math.min(src.getHeight(), dstIn.getHeight()), dstOut.getHeight());
     169            Object srcPixel = null;
     170            Object dstPixel = null;
     171            Object outPixel = null;
     172            for (int y = 0; y < h; y++) {
     173                for (int x = 0; x < w; x++) {
     174                    srcPixel = src.getDataElements(x, y, srcPixel);
     175                    dstPixel = dstIn.getDataElements(x, y, dstPixel);
     176                    int result = composePixel(mode, srcColorModel.getRGB(srcPixel), dstColorModel.getRGB(dstPixel));
     177                    outPixel = dstColorModel.getDataElements(result, outPixel);
     178                    dstOut.setDataElements(x, y, outPixel);
     179                }
     180            }
     181        }
     182
     183        @Override
     184        public void dispose() {
     185            // nothing to dispose
     186        }
     187    }
     188}
  • src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java

    diff --git src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java src/org/openstreetmap/josm/gui/layer/AbstractTileSourceLayer.java
    index 510fad35d1..f5f96bca4c 100644
    package org.openstreetmap.josm.gui.layer;  
    44import static org.openstreetmap.josm.tools.I18n.marktr;
    55import static org.openstreetmap.josm.tools.I18n.tr;
    66
     7import java.awt.BasicStroke;
    78import java.awt.Color;
    89import java.awt.Component;
    910import java.awt.Dimension;
    import java.awt.GridBagLayout;  
    1516import java.awt.Image;
    1617import java.awt.Shape;
    1718import java.awt.Toolkit;
     19import java.awt.Stroke;
    1820import java.awt.event.ActionEvent;
    1921import java.awt.event.MouseAdapter;
    2022import java.awt.event.MouseEvent;
    import org.openstreetmap.josm.data.imagery.vectortile.VectorTile;  
    9395import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
    9496import org.openstreetmap.josm.data.preferences.BooleanProperty;
    9597import org.openstreetmap.josm.data.preferences.IntegerProperty;
     98import org.openstreetmap.josm.data.preferences.NamedColorProperty;
    9699import org.openstreetmap.josm.data.projection.Projection;
    97100import org.openstreetmap.josm.data.projection.ProjectionRegistry;
    98101import org.openstreetmap.josm.data.projection.Projections;
    import org.openstreetmap.josm.gui.layer.imagery.LoadErroneousTilesAction;  
    116119import org.openstreetmap.josm.gui.layer.imagery.MVTLayer;
    117120import org.openstreetmap.josm.gui.layer.imagery.ReprojectionTile;
    118121import org.openstreetmap.josm.gui.layer.imagery.ShowErrorsAction;
     122import org.openstreetmap.josm.gui.layer.imagery.ShowTileBordersAction;
    119123import org.openstreetmap.josm.gui.layer.imagery.TileAnchor;
    120124import org.openstreetmap.josm.gui.layer.imagery.TileCoordinateConverter;
    121125import org.openstreetmap.josm.gui.layer.imagery.TilePosition;
    implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi  
    181185    public static final IntegerProperty ZOOM_OFFSET = new IntegerProperty(PREFERENCE_PREFIX + ".zoom_offset", 0);
    182186
    183187    private static final BooleanProperty POPUP_MENU_ENABLED = new BooleanProperty(PREFERENCE_PREFIX + ".popupmenu", true);
     188    /** Color of the border drawn around each tile if enabled, see {@link TileSourceDisplaySettings#isShowTileBorders()} */
     189    private static final NamedColorProperty TILE_BORDER_COLOR = new NamedColorProperty(marktr("imagery tile border"), new Color(0, 0, 0, 96));
     190    private static final Stroke TILE_BORDER_STROKE = new BasicStroke(1f);
    184191    private static final String ERROR_STRING = marktr("Error");
    185192
    186193    /*
    implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi  
    12261233            //texty += 1 + fontHeight;
    12271234        }
    12281235
    1229         if (Logging.isDebugEnabled()) {
     1236        if (getDisplaySettings().isShowTileBorders()) {
     1237            // draw a thin border around the tile
     1238            Color oldColor = g.getColor();
     1239            Stroke oldStroke = g.getStroke();
     1240            g.setColor(TILE_BORDER_COLOR.get());
     1241            g.setStroke(TILE_BORDER_STROKE);
     1242            g.draw(coordinateConverter.getTileShapeScreen(tile));
     1243            g.setStroke(oldStroke);
     1244            g.setColor(oldColor);
     1245        } else if (Logging.isDebugEnabled()) {
    12301246            // draw tile outline in semi-transparent red
    12311247            g.setColor(new Color(255, 0, 0, 50));
    12321248            g.draw(coordinateConverter.getTileShapeScreen(tile));
    implements ImageObserver, TileLoaderListener, ZoomChangeListener, FilterChangeLi  
    18531869            new AutoLoadTilesAction(this),
    18541870            new AutoZoomAction(this),
    18551871            new ShowErrorsAction(this),
     1872            new ShowTileBordersAction(this),
    18561873            new IncreaseZoomAction(this),
    18571874            new DecreaseZoomAction(this),
    18581875            new ZoomToBestAction(this),
  • new file src/org/openstreetmap/josm/gui/layer/MapGridPaintable.java

    diff --git src/org/openstreetmap/josm/gui/layer/MapGridPaintable.java src/org/openstreetmap/josm/gui/layer/MapGridPaintable.java
    new file mode 100644
    index 0000000000..221c6041f9
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer;
     3
     4import static org.openstreetmap.josm.tools.I18n.marktr;
     5
     6import java.awt.BasicStroke;
     7import java.awt.Color;
     8import java.awt.Graphics2D;
     9import java.awt.RenderingHints;
     10import java.awt.geom.Line2D;
     11import java.awt.geom.Path2D;
     12import java.awt.geom.Point2D;
     13import java.util.ArrayList;
     14import java.util.List;
     15
     16import org.openstreetmap.josm.data.Bounds;
     17import org.openstreetmap.josm.data.ProjectionBounds;
     18import org.openstreetmap.josm.data.coor.EastNorth;
     19import org.openstreetmap.josm.data.coor.ILatLon;
     20import org.openstreetmap.josm.data.coor.LatLon;
     21import org.openstreetmap.josm.data.preferences.BooleanProperty;
     22import org.openstreetmap.josm.data.preferences.DoubleProperty;
     23import org.openstreetmap.josm.data.preferences.EnumProperty;
     24import org.openstreetmap.josm.data.preferences.NamedColorProperty;
     25import org.openstreetmap.josm.data.projection.Projection;
     26import org.openstreetmap.josm.gui.MapView;
     27import org.openstreetmap.josm.gui.draw.BlendComposite;
     28import org.openstreetmap.josm.spi.preferences.Config;
     29import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
     30import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
     31import org.openstreetmap.josm.tools.Destroyable;
     32import org.openstreetmap.josm.tools.Logging;
     33
     34/**
     35 * A grid drawn over the whole map view, on top of all layers.
     36 * <p>
     37 * The grid is a pure visual aid (there is no snapping to it). It is either aligned to latitude/longitude, with a
     38 * spacing in degrees, or to the projected coordinates, with a spacing in metres (true distance, measured at the
     39 * grid origin), an optional rotation and an origin offset. When the grid cells would become
     40 * smaller than a minimal size on screen, the spacing is multiplied by 10 until the cells are large enough, so the
     41 * grid stays readable at every zoom level while remaining aligned to the configured one.
     42 * <p>
     43 * All settings are preferences (prefix {@code draw.grid.}), see {@link org.openstreetmap.josm.gui.preferences.display.GridPreference}.
     44 * <p>
     45 * An instance registers itself as a preference listener, so {@link #destroy()} must be called when it is no
     46 * longer used ({@link org.openstreetmap.josm.gui.MapFrame} does this), otherwise the listener is leaked.
     47 * @since xxx
     48 */
     49public class MapGridPaintable extends AbstractMapViewPaintable implements PreferenceChangedListener, Destroyable {
     50
     51    /**
     52     * The kind of coordinates a grid is aligned to.
     53     */
     54    public enum GridType {
     55        /** lines of constant latitude and longitude, spacing in degrees */
     56        LATLON,
     57        /** lines of constant projected east/north coordinate (optionally rotated), spacing in metres */
     58        PROJECTED
     59    }
     60
     61    private static final String PREFIX = "draw.grid.";
     62
     63    /** Whether the grid is shown */
     64    public static final BooleanProperty ENABLED = new BooleanProperty(PREFIX + "enabled", false);
     65    /** The kind of grid */
     66    public static final EnumProperty<GridType> TYPE = new EnumProperty<>(PREFIX + "type", GridType.class, GridType.PROJECTED);
     67    /** Spacing of the vertical lines (longitude resp. east), in degrees resp. metres (true distance at the origin) */
     68    public static final DoubleProperty SPACING_X = new DoubleProperty(PREFIX + "spacing-x", 1000);
     69    /** Spacing of the horizontal lines (latitude resp. north), in degrees resp. metres (true distance at the origin) */
     70    public static final DoubleProperty SPACING_Y = new DoubleProperty(PREFIX + "spacing-y", 1000);
     71    /** Rotation of a projected grid, in degrees counter clockwise */
     72    public static final DoubleProperty ROTATION = new DoubleProperty(PREFIX + "rotation", 0);
     73    /** Origin of the grid: a grid line passes through this coordinate (longitude resp. east) */
     74    public static final DoubleProperty ORIGIN_X = new DoubleProperty(PREFIX + "origin-x", 0);
     75    /** Origin of the grid: a grid line passes through this coordinate (latitude resp. north) */
     76    public static final DoubleProperty ORIGIN_Y = new DoubleProperty(PREFIX + "origin-y", 0);
     77    /** Below this distance between lines (in pixels) the spacing is multiplied by 10 */
     78    public static final DoubleProperty MIN_PIXEL_SPACING = new DoubleProperty(PREFIX + "min-pixel-spacing", 25);
     79    /** The blend mode used to draw the lines */
     80    public static final EnumProperty<BlendComposite.Mode> BLEND_MODE
     81            = new EnumProperty<>(PREFIX + "blend-mode", BlendComposite.Mode.class, BlendComposite.Mode.HARD_LIGHT);
     82    /** Width of the lines in pixels */
     83    public static final DoubleProperty LINE_WIDTH = new DoubleProperty(PREFIX + "line-width", 2);
     84    /** Color of the lines of constant east coordinate resp. longitude (the lines running north-south) */
     85    public static final NamedColorProperty COLOR_EAST = new NamedColorProperty(marktr("grid east lines"), Color.YELLOW);
     86    /** Color of the lines of constant north coordinate resp. latitude (the lines running east-west) */
     87    public static final NamedColorProperty COLOR_NORTH = new NamedColorProperty(marktr("grid north lines"), Color.YELLOW);
     88
     89    /**
     90     * A straight line of a projected grid.
     91     * @since xxx
     92     */
     93    public static final class ProjectedGridLine {
     94        /** start point */
     95        public final EastNorth start;
     96        /** end point */
     97        public final EastNorth end;
     98        /** {@code true} for a line of constant (rotated) east coordinate, {@code false} for constant north */
     99        public final boolean constantEast;
     100
     101        ProjectedGridLine(EastNorth start, EastNorth end, boolean constantEast) {
     102            this.start = start;
     103            this.end = end;
     104            this.constantEast = constantEast;
     105        }
     106
     107        @Override
     108        public String toString() {
     109            return (constantEast ? "east " : "north ") + start + " -> " + end;
     110        }
     111    }
     112
     113    /**
     114     * A (curved) line of a latitude/longitude grid.
     115     * @since xxx
     116     */
     117    public static final class LatLonGridLine {
     118        /** the points of the polyline */
     119        public final List<LatLon> points;
     120        /** {@code true} for a meridian (constant longitude), {@code false} for a parallel (constant latitude) */
     121        public final boolean meridian;
     122
     123        LatLonGridLine(List<LatLon> points, boolean meridian) {
     124            this.points = points;
     125            this.meridian = meridian;
     126        }
     127
     128        @Override
     129        public String toString() {
     130            return (meridian ? "meridian " : "parallel ") + points;
     131        }
     132    }
     133
     134    /** number of segments used to draw a curved (lat/lon) grid line across the view */
     135    private static final int CURVE_SEGMENTS = 32;
     136    /** hard limit for the number of lines in one direction, whatever the settings are */
     137    private static final int MAX_LINES = 500;
     138
     139    /** written by {@link #paint} and read by {@link #preferenceChanged}, which may run on another thread */
     140    private volatile MapView mapView;
     141
     142    /**
     143     * Constructs a new {@code MapGridPaintable}.
     144     */
     145    public MapGridPaintable() {
     146        Config.getPref().addPreferenceChangeListener(this);
     147    }
     148
     149    @Override
     150    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
     151        mapView = mv;
     152        if (!Boolean.TRUE.equals(ENABLED.get()) || mv.getWidth() <= 0 || mv.getHeight() <= 0) {
     153            return;
     154        }
     155        Graphics2D g2 = (Graphics2D) g.create();
     156        try {
     157            g2.setStroke(new BasicStroke((float) Math.max(0.1, LINE_WIDTH.get())));
     158            g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
     159                    Config.getPref().getBoolean("mappaint.use-antialiasing", true)
     160                    ? RenderingHints.VALUE_ANTIALIAS_ON : RenderingHints.VALUE_ANTIALIAS_OFF);
     161            BlendComposite.Mode mode = BLEND_MODE.get();
     162            if (mode != null && mode != BlendComposite.Mode.NORMAL) {
     163                g2.setComposite(BlendComposite.getInstance(mode));
     164            }
     165            if (TYPE.get() == GridType.PROJECTED) {
     166                paintProjectedGrid(g2, mv);
     167            } else {
     168                paintLatLonGrid(g2, mv);
     169            }
     170        } finally {
     171            g2.dispose();
     172        }
     173    }
     174
     175    private static void paintProjectedGrid(Graphics2D g, MapView mv) {
     176        double sx = SPACING_X.get();
     177        double sy = SPACING_Y.get();
     178        if (sx <= 0 || sy <= 0) {
     179            return;
     180        }
     181        // the spacing is a true distance in metres, measured at the grid origin
     182        EastNorth origin = new EastNorth(ORIGIN_X.get(), ORIGIN_Y.get());
     183        double unitsPerMetre = projectionUnitsPerMetre(mv.getProjection(), origin);
     184        sx *= unitsPerMetre;
     185        sy *= unitsPerMetre;
     186        // thin out the grid until the cells are large enough on screen
     187        double factor = thinningFactor(Math.min(sx, sy) / mv.getScale());
     188        List<ProjectedGridLine> lines = getProjectedGridLines(mv.getProjectionBounds(), sx * factor, sy * factor,
     189                ROTATION.get(), origin.east(), origin.north());
     190        for (ProjectedGridLine line : lines) {
     191            g.setColor(line.constantEast ? COLOR_EAST.get() : COLOR_NORTH.get());
     192            g.draw(new Line2D.Double(mv.getPoint2D(line.start), mv.getPoint2D(line.end)));
     193        }
     194    }
     195
     196    private static void paintLatLonGrid(Graphics2D g, MapView mv) {
     197        double sx = SPACING_X.get();
     198        double sy = SPACING_Y.get();
     199        if (sx <= 0 || sy <= 0) {
     200            return;
     201        }
     202        Bounds view = mv.getRealBounds();
     203        // pixel size of one cell at the center of the view
     204        LatLon center = view.getCenter();
     205        Point2D c = mv.getPoint2D(center);
     206        Point2D cx = mv.getPoint2D(new LatLon(center.lat(), Math.min(180, center.lon() + sx)));
     207        Point2D cy = mv.getPoint2D(new LatLon(Math.min(89, center.lat() + sy), center.lon()));
     208        double factor = thinningFactor(Math.min(c.distance(cx), c.distance(cy)));
     209        List<LatLonGridLine> lines = getLatLonGridLines(view, mv.getProjection().getWorldBoundsLatLon(),
     210                sx * factor, sy * factor, ORIGIN_X.get(), ORIGIN_Y.get(), CURVE_SEGMENTS);
     211        for (LatLonGridLine line : lines) {
     212            g.setColor(line.meridian ? COLOR_EAST.get() : COLOR_NORTH.get());
     213            Path2D.Double path = new Path2D.Double();
     214            boolean first = true;
     215            for (LatLon ll : line.points) {
     216                Point2D p = mv.getPoint2D(ll);
     217                if (first) {
     218                    path.moveTo(p.getX(), p.getY());
     219                    first = false;
     220                } else {
     221                    path.lineTo(p.getX(), p.getY());
     222                }
     223            }
     224            g.draw(path);
     225        }
     226    }
     227
     228    /**
     229     * Computes how many projection units correspond to one metre on the ground at the given position. For
     230     * conformal projections (e.g. Mercator) this is the local scale factor, for a Mercator grid at 60° latitude
     231     * one metre is two projection units.
     232     * @param projection the projection
     233     * @param at the position (projected coordinates)
     234     * @return projection units per metre; 1 if it cannot be determined (position outside the world)
     235     */
     236    public static double projectionUnitsPerMetre(Projection projection, EastNorth at) {
     237        try {
     238            if (!projection.getWorldBoundsBoxEastNorth().contains(at)) {
     239                return 1;
     240            }
     241            double step = 100;
     242            LatLon a = projection.eastNorth2latlon(at);
     243            LatLon b = projection.eastNorth2latlon(new EastNorth(at.east() + step, at.north()));
     244            if (!a.isValid() || !b.isValid()) {
     245                return 1;
     246            }
     247            double metres = a.greatCircleDistance((ILatLon) b);
     248            return metres > 0 && Double.isFinite(metres) ? step / metres : 1;
     249        } catch (IllegalArgumentException e) {
     250            Logging.trace(e);
     251            return 1;
     252        }
     253    }
     254
     255    /**
     256     * Computes the factor (a power of 10) by which the spacing must be multiplied so that the lines are at least
     257     * {@link #MIN_PIXEL_SPACING} apart.
     258     * @param pixelSpacing the distance between two lines on screen, in pixels
     259     * @return the factor (at least 1)
     260     */
     261    static double thinningFactor(double pixelSpacing) {
     262        double min = Math.max(1, MIN_PIXEL_SPACING.get());
     263        double factor = 1;
     264        if (pixelSpacing <= 0 || Double.isNaN(pixelSpacing)) {
     265            return factor;
     266        }
     267        while (pixelSpacing * factor < min && factor < 1e15) {
     268            factor *= 10;
     269        }
     270        return factor;
     271    }
     272
     273    /**
     274     * Computes the lines of a (possibly rotated) grid in projected coordinates which cross the given area.
     275     * @param area the area to cover
     276     * @param spacingX distance between the lines running in the "north" direction of the grid (before rotation)
     277     * @param spacingY distance between the lines running in the "east" direction of the grid (before rotation)
     278     * @param rotationDegrees rotation of the grid, counter clockwise
     279     * @param originX east coordinate of the grid origin
     280     * @param originY north coordinate of the grid origin
     281     * @return the lines; empty if the spacing is invalid or there would be too many lines
     282     */
     283    public static List<ProjectedGridLine> getProjectedGridLines(ProjectionBounds area, double spacingX, double spacingY,
     284            double rotationDegrees, double originX, double originY) {
     285        List<ProjectedGridLine> lines = new ArrayList<>();
     286        if (!(spacingX > 0) || !(spacingY > 0)) {
     287            return lines;
     288        }
     289        double angle = Math.toRadians(rotationDegrees);
     290        double c = Math.cos(angle);
     291        double s = Math.sin(angle);
     292        // grid coordinates (u along the rotated east axis, v along the rotated north axis) of the area corners
     293        double[] xs = {area.minEast, area.maxEast, area.maxEast, area.minEast};
     294        double[] ys = {area.minNorth, area.minNorth, area.maxNorth, area.maxNorth};
     295        double uMin = Double.POSITIVE_INFINITY;
     296        double uMax = Double.NEGATIVE_INFINITY;
     297        double vMin = Double.POSITIVE_INFINITY;
     298        double vMax = Double.NEGATIVE_INFINITY;
     299        for (int i = 0; i < 4; i++) {
     300            double dx = xs[i] - originX;
     301            double dy = ys[i] - originY;
     302            double u = c * dx + s * dy;
     303            double v = -s * dx + c * dy;
     304            uMin = Math.min(uMin, u);
     305            uMax = Math.max(uMax, u);
     306            vMin = Math.min(vMin, v);
     307            vMax = Math.max(vMax, v);
     308        }
     309        long kuMin = (long) Math.ceil(uMin / spacingX);
     310        long kuMax = (long) Math.floor(uMax / spacingX);
     311        long kvMin = (long) Math.ceil(vMin / spacingY);
     312        long kvMax = (long) Math.floor(vMax / spacingY);
     313        if (kuMax - kuMin > MAX_LINES || kvMax - kvMin > MAX_LINES) {
     314            return lines;
     315        }
     316        // lines of constant u run along the v axis
     317        for (long k = kuMin; k <= kuMax; k++) {
     318            double u = k * spacingX;
     319            lines.add(new ProjectedGridLine(gridToEastNorth(u, vMin, c, s, originX, originY),
     320                    gridToEastNorth(u, vMax, c, s, originX, originY), true));
     321        }
     322        for (long k = kvMin; k <= kvMax; k++) {
     323            double v = k * spacingY;
     324            lines.add(new ProjectedGridLine(gridToEastNorth(uMin, v, c, s, originX, originY),
     325                    gridToEastNorth(uMax, v, c, s, originX, originY), false));
     326        }
     327        return lines;
     328    }
     329
     330    private static EastNorth gridToEastNorth(double u, double v, double c, double s, double originX, double originY) {
     331        return new EastNorth(originX + c * u - s * v, originY + s * u + c * v);
     332    }
     333
     334    /**
     335     * Computes the lines of a latitude/longitude grid which cross the given area. Since these lines are curves in
     336     * most projections, each line is returned as a polyline.
     337     * @param area the area to cover
     338     * @param world the bounds of the world in the current projection, the lines are clamped to it
     339     * @param spacingLon distance between the meridians, in degrees
     340     * @param spacingLat distance between the parallels, in degrees
     341     * @param originLon longitude of a meridian of the grid
     342     * @param originLat latitude of a parallel of the grid
     343     * @param segments number of segments of each polyline
     344     * @return the lines; empty if the spacing is invalid or there would be too many lines
     345     */
     346    public static List<LatLonGridLine> getLatLonGridLines(Bounds area, Bounds world, double spacingLon, double spacingLat,
     347            double originLon, double originLat, int segments) {
     348        List<LatLonGridLine> lines = new ArrayList<>();
     349        if (!(spacingLon > 0) || !(spacingLat > 0) || segments < 1) {
     350            return lines;
     351        }
     352        double minLat = Math.max(area.getMinLat(), world.getMinLat());
     353        double maxLat = Math.min(area.getMaxLat(), world.getMaxLat());
     354        double minLon = area.getMinLon();
     355        double maxLon = area.getMaxLon();
     356        if (maxLon < minLon) {
     357            maxLon += 360; // the view crosses the antimeridian
     358        }
     359        if (minLat >= maxLat || minLon >= maxLon) {
     360            return lines;
     361        }
     362        long kLonMin = (long) Math.ceil((minLon - originLon) / spacingLon);
     363        long kLonMax = (long) Math.floor((maxLon - originLon) / spacingLon);
     364        long kLatMin = (long) Math.ceil((minLat - originLat) / spacingLat);
     365        long kLatMax = (long) Math.floor((maxLat - originLat) / spacingLat);
     366        if (kLonMax - kLonMin > MAX_LINES || kLatMax - kLatMin > MAX_LINES) {
     367            return lines;
     368        }
     369        // for a projection which does not span the whole globe, meridians outside its longitude range are
     370        // skipped like the parallels below; a full range must not be checked since longitudes wrap around
     371        boolean limitedLon = world.getMinLon() > -180 || world.getMaxLon() < 180;
     372        for (long k = kLonMin; k <= kLonMax; k++) {
     373            double lon = LatLon.toIntervalLon(originLon + k * spacingLon);
     374            if (limitedLon && (lon < world.getMinLon() || lon > world.getMaxLon())) {
     375                continue;
     376            }
     377            List<LatLon> line = new ArrayList<>(segments + 1);
     378            for (int i = 0; i <= segments; i++) {
     379                line.add(new LatLon(minLat + (maxLat - minLat) * i / segments, lon));
     380            }
     381            lines.add(new LatLonGridLine(line, true));
     382        }
     383        for (long k = kLatMin; k <= kLatMax; k++) {
     384            double lat = originLat + k * spacingLat;
     385            if (lat < world.getMinLat() || lat > world.getMaxLat()) {
     386                continue;
     387            }
     388            List<LatLon> line = new ArrayList<>(segments + 1);
     389            for (int i = 0; i <= segments; i++) {
     390                line.add(new LatLon(lat, LatLon.toIntervalLon(minLon + (maxLon - minLon) * i / segments)));
     391            }
     392            lines.add(new LatLonGridLine(line, false));
     393        }
     394        return lines;
     395    }
     396
     397    @Override
     398    public void preferenceChanged(PreferenceChangeEvent e) {
     399        if (e.getKey().startsWith(PREFIX) || e.getKey().equals(COLOR_EAST.getKey()) || e.getKey().equals(COLOR_NORTH.getKey())) {
     400            invalidate();
     401            if (mapView != null) {
     402                mapView.repaint();
     403            }
     404        }
     405    }
     406
     407    @Override
     408    public void destroy() {
     409        Config.getPref().removePreferenceChangeListener(this);
     410        mapView = null;
     411    }
     412}
  • new file src/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersAction.java

    diff --git src/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersAction.java src/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersAction.java
    new file mode 100644
    index 0000000000..849829d807
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.imagery;
     3
     4import static org.openstreetmap.josm.tools.I18n.tr;
     5
     6import java.awt.Component;
     7import java.awt.event.ActionEvent;
     8import java.util.List;
     9
     10import javax.swing.AbstractAction;
     11import javax.swing.JCheckBoxMenuItem;
     12
     13import org.openstreetmap.josm.gui.layer.AbstractTileSourceLayer;
     14import org.openstreetmap.josm.gui.layer.Layer;
     15import org.openstreetmap.josm.gui.layer.Layer.LayerAction;
     16
     17/**
     18 * Toggles the drawing of a thin border around each tile of an imagery layer.
     19 * @since xxx
     20 */
     21public class ShowTileBordersAction extends AbstractAction implements LayerAction {
     22
     23    private final AbstractTileSourceLayer<?> layer;
     24
     25    /**
     26     * Constructs a new {@code ShowTileBordersAction}.
     27     * @param layer imagery layer
     28     */
     29    public ShowTileBordersAction(AbstractTileSourceLayer<?> layer) {
     30        super(tr("Show tile borders"));
     31        this.layer = layer;
     32    }
     33
     34    @Override
     35    public void actionPerformed(ActionEvent ae) {
     36        TileSourceDisplaySettings settings = layer.getDisplaySettings();
     37        boolean show = !settings.isShowTileBorders();
     38        settings.setShowTileBorders(show);
     39        // remember the choice, so that it also applies to layers created later and after a restart. Only the
     40        // action does this, not the setter, so that loading a session does not overwrite the preference.
     41        TileSourceDisplaySettings.PROP_SHOW_TILE_BORDERS.put(show);
     42    }
     43
     44    @Override
     45    public Component createMenuComponent() {
     46        JCheckBoxMenuItem item = new JCheckBoxMenuItem(this);
     47        item.setSelected(layer.getDisplaySettings().isShowTileBorders());
     48        return item;
     49    }
     50
     51    @Override
     52    public boolean supportLayers(List<Layer> layers) {
     53        return AbstractTileSourceLayer.actionSupportLayers(layers);
     54    }
     55}
  • src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java

    diff --git src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java src/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettings.java
    index 4496fb6027..13497a3ac6 100644
    public class TileSourceDisplaySettings implements SessionAwareReadApply {  
    4545     */
    4646    private static final String SHOW_ERRORS = "show-errors";
    4747
     48    private static final String SHOW_TILE_BORDERS = "show-tile-borders";
     49
    4850    private static final String DISPLACEMENT = "displacement";
    4951
    5052    private static final String PREFERENCE_PREFIX = "imagery.generic";
    public class TileSourceDisplaySettings implements SessionAwareReadApply {  
    5961     */
    6062    public static final BooleanProperty PROP_AUTO_ZOOM = new BooleanProperty(PREFERENCE_PREFIX + ".default_autozoom", true);
    6163
     64    /**
     65     * The default tile borders property, remembered whenever the user toggles them, so that the choice also
     66     * applies to layers created later and after a restart
     67     * @since xxx
     68     */
     69    public static final BooleanProperty PROP_SHOW_TILE_BORDERS
     70            = new BooleanProperty(PREFERENCE_PREFIX + ".default_showtileborders", false);
     71
    6272
    6373    /** if layers changes automatically, when user zooms in */
    6474    private boolean autoZoom;
    public class TileSourceDisplaySettings implements SessionAwareReadApply {  
    6676    private boolean autoLoad;
    6777    /** if layer should show errors on tiles */
    6878    private boolean showErrors;
     79    /** if layer should draw a border around each tile */
     80    private boolean showTileBorders;
    6981
    7082    private OffsetBookmark previousOffsetBookmark;
    7183    private OffsetBookmark offsetBookmark;
    public class TileSourceDisplaySettings implements SessionAwareReadApply {  
    97109        autoZoom = getProperty(prefixes, "default_autozoom", PROP_AUTO_ZOOM.getDefaultValue());
    98110        autoLoad = getProperty(prefixes, "default_autoload", PROP_AUTO_LOAD.getDefaultValue());
    99111        showErrors = getProperty(prefixes, "default_showerrors", Boolean.TRUE);
     112        showTileBorders = getProperty(prefixes, "default_showtileborders", PROP_SHOW_TILE_BORDERS.getDefaultValue());
    100113    }
    101114
    102115    private static boolean getProperty(String[] prefixes, String name, Boolean def) {
    103         // iterate through all values to force the preferences to receive the default value.
    104         // we only support a default value of true.
    105         boolean value = true;
     116        // iterate through all values to force the preferences to receive the default value
     117        boolean value = def;
    106118        for (String p : prefixes) {
    107119            String key = p + "." + name;
    108             boolean currentValue = Config.getPref().getBoolean(key, true);
     120            boolean currentValue = Config.getPref().getBoolean(key, def);
    109121            if (!Config.getPref().get(key, def.toString()).isEmpty()) {
    110122                value = currentValue;
    111123            }
    public class TileSourceDisplaySettings implements SessionAwareReadApply {  
    170182        fireSettingsChange(SHOW_ERRORS);
    171183    }
    172184
     185    /**
     186     * If the layer should draw a thin border around each tile.
     187     * @return <code>true</code> to draw tile borders.
     188     * @since xxx
     189     */
     190    public boolean isShowTileBorders() {
     191        return showTileBorders;
     192    }
     193
     194    /**
     195     * Sets the show tile borders property. Fires a change event.
     196     * @param showTileBorders {@code true} if the layer should draw a thin border around each tile
     197     * @see #isShowTileBorders()
     198     * @since xxx
     199     */
     200    public void setShowTileBorders(boolean showTileBorders) {
     201        this.showTileBorders = showTileBorders;
     202        fireSettingsChange(SHOW_TILE_BORDERS);
     203    }
     204
    173205    /**
    174206     * Gets the displacement in x (east) direction
    175207     * @return The displacement.
    public class TileSourceDisplaySettings implements SessionAwareReadApply {  
    290322        data.put(AUTO_LOAD, Boolean.toString(autoLoad));
    291323        data.put(AUTO_ZOOM, Boolean.toString(autoZoom));
    292324        data.put(SHOW_ERRORS, Boolean.toString(showErrors));
     325        data.put(SHOW_TILE_BORDERS, Boolean.toString(showTileBorders));
    293326        return data;
    294327    }
    295328
    public class TileSourceDisplaySettings implements SessionAwareReadApply {  
    317350            if (doShowErrors != null) {
    318351                setShowErrors(Boolean.parseBoolean(doShowErrors));
    319352            }
     353
     354            String doShowTileBorders = data.get(SHOW_TILE_BORDERS);
     355            if (doShowTileBorders != null) {
     356                setShowTileBorders(Boolean.parseBoolean(doShowTileBorders));
     357            }
    320358        } catch (JosmRuntimeException | IllegalArgumentException | IllegalStateException e) {
    321359            throw BugReport.intercept(e).put("data", data);
    322360        }
    public class TileSourceDisplaySettings implements SessionAwareReadApply {  
    324362
    325363    @Override
    326364    public int hashCode() {
    327         return Objects.hash(autoLoad, autoZoom, showErrors);
     365        return Objects.hash(autoLoad, autoZoom, showErrors, showTileBorders);
    328366    }
    329367
    330368    @Override
    public class TileSourceDisplaySettings implements SessionAwareReadApply {  
    336374        TileSourceDisplaySettings other = (TileSourceDisplaySettings) obj;
    337375        return autoLoad == other.autoLoad
    338376            && autoZoom == other.autoZoom
    339             && showErrors == other.showErrors;
     377            && showErrors == other.showErrors
     378            && showTileBorders == other.showTileBorders;
    340379    }
    341380
    342381    @Override
    343382    public String toString() {
    344383        return "TileSourceDisplaySettings [autoZoom=" + autoZoom + ", autoLoad=" + autoLoad + ", showErrors="
    345                 + showErrors + ']';
     384                + showErrors + ", showTileBorders=" + showTileBorders + ']';
    346385    }
    347386
    348387    /**
  • src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java

    diff --git src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java src/org/openstreetmap/josm/gui/preferences/PreferenceTabbedPane.java
    index 61b8c82993..d1d8431b26 100644
    import org.openstreetmap.josm.gui.preferences.display.ColorPreference;  
    5151import org.openstreetmap.josm.gui.preferences.display.DisplayPreference;
    5252import org.openstreetmap.josm.gui.preferences.display.DrawingPreference;
    5353import org.openstreetmap.josm.gui.preferences.display.GPXPreference;
     54import org.openstreetmap.josm.gui.preferences.display.GridPreference;
    5455import org.openstreetmap.josm.gui.preferences.display.LafPreference;
    5556import org.openstreetmap.josm.gui.preferences.display.LanguagePreference;
    5657import org.openstreetmap.josm.gui.preferences.imagery.ImageryPreference;
    public final class PreferenceTabbedPane extends JTabbedPane implements ExpertMod  
    610611        SETTINGS_FACTORIES.add(new ServerAccessPreference.Factory());
    611612        SETTINGS_FACTORIES.add(new ProxyPreference.Factory());
    612613        SETTINGS_FACTORIES.add(new ProjectionPreference.Factory());
     614        SETTINGS_FACTORIES.add(new GridPreference.Factory());
    613615        SETTINGS_FACTORIES.add(new MapPaintPreference.Factory());
    614616        SETTINGS_FACTORIES.add(new TaggingPresetPreference.Factory());
    615617        SETTINGS_FACTORIES.add(new BackupPreference.Factory());
  • new file src/org/openstreetmap/josm/gui/preferences/display/GridPreference.java

    diff --git src/org/openstreetmap/josm/gui/preferences/display/GridPreference.java src/org/openstreetmap/josm/gui/preferences/display/GridPreference.java
    new file mode 100644
    index 0000000000..f2bc5198f3
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.preferences.display;
     3
     4import static org.openstreetmap.josm.tools.I18n.tr;
     5
     6import java.awt.Component;
     7import java.awt.GridBagLayout;
     8import java.text.DecimalFormat;
     9import java.text.DecimalFormatSymbols;
     10import java.text.ParseException;
     11import java.util.Locale;
     12import java.util.function.Function;
     13
     14import javax.swing.DefaultListCellRenderer;
     15import javax.swing.JCheckBox;
     16import javax.swing.JLabel;
     17import javax.swing.JList;
     18import javax.swing.JPanel;
     19
     20import org.openstreetmap.josm.data.preferences.DoubleProperty;
     21import org.openstreetmap.josm.gui.draw.BlendComposite;
     22import org.openstreetmap.josm.gui.help.HelpUtil;
     23import org.openstreetmap.josm.gui.layer.MapGridPaintable;
     24import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
     25import org.openstreetmap.josm.gui.preferences.DefaultTabPreferenceSetting;
     26import org.openstreetmap.josm.gui.preferences.PreferenceSetting;
     27import org.openstreetmap.josm.gui.preferences.PreferenceSettingFactory;
     28import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane;
     29import org.openstreetmap.josm.gui.widgets.JosmComboBox;
     30import org.openstreetmap.josm.gui.widgets.JosmTextField;
     31import org.openstreetmap.josm.tools.GBC;
     32import org.openstreetmap.josm.tools.Logging;
     33
     34/**
     35 * Settings of the grid drawn over the map, see {@link MapGridPaintable}.
     36 * @since xxx
     37 */
     38public class GridPreference extends DefaultTabPreferenceSetting {
     39
     40    /**
     41     * Factory used to create a new {@code GridPreference}.
     42     */
     43    public static class Factory implements PreferenceSettingFactory {
     44        @Override
     45        public PreferenceSetting createPreferenceSetting() {
     46            return new GridPreference();
     47        }
     48    }
     49
     50    private static final DecimalFormat FORMAT = new DecimalFormat("0.#########", DecimalFormatSymbols.getInstance(Locale.ROOT));
     51
     52    private final JCheckBox enabled = new JCheckBox(tr("Show grid"));
     53    private final JosmComboBox<GridType> type = new JosmComboBox<>(GridType.values());
     54    private final JosmTextField spacingX = new JosmTextField(10);
     55    private final JosmTextField spacingY = new JosmTextField(10);
     56    private final JosmTextField rotation = new JosmTextField(10);
     57    private final JosmTextField originX = new JosmTextField(10);
     58    private final JosmTextField originY = new JosmTextField(10);
     59    private final JosmTextField minPixelSpacing = new JosmTextField(10);
     60    private final JosmTextField lineWidth = new JosmTextField(10);
     61    private final JosmComboBox<BlendComposite.Mode> blendMode = new JosmComboBox<>(BlendComposite.Mode.values());
     62    private final JLabel spacingXLabel = new JLabel();
     63    private final JLabel spacingYLabel = new JLabel();
     64    private final JLabel originXLabel = new JLabel();
     65    private final JLabel originYLabel = new JLabel();
     66
     67    GridPreference() {
     68        super("preferences/grid", tr("Grid"), tr("Settings of the grid drawn over the map."));
     69    }
     70
     71    @Override
     72    public void addGui(PreferenceTabbedPane gui) {
     73        JPanel panel = new JPanel(new GridBagLayout());
     74
     75        enabled.setSelected(Boolean.TRUE.equals(MapGridPaintable.ENABLED.get()));
     76        enabled.setToolTipText(tr("Draw a grid over the map (View menu: Show grid). "
     77                + "The grid is a visual aid only, there is no snapping to it."));
     78        type.setSelectedItem(MapGridPaintable.TYPE.get());
     79        type.setRenderer(new TranslatedRenderer<>(t -> t == GridType.PROJECTED ? tr("Projected coordinates") : tr("Latitude/longitude")));
     80        type.setToolTipText("<html>" + tr("Latitude/longitude: lines of constant latitude and longitude, spacing in degrees.<br>"
     81                + "Projected: lines of constant east/north coordinate of the map projection, "
     82                + "spacing in metres, optionally rotated.") + "</html>");
     83        type.addActionListener(e -> updateLabels());
     84        set(spacingX, MapGridPaintable.SPACING_X);
     85        set(spacingY, MapGridPaintable.SPACING_Y);
     86        set(rotation, MapGridPaintable.ROTATION);
     87        rotation.setToolTipText(tr("Rotation of a projected grid in degrees, counter clockwise. Ignored for a latitude/longitude grid."));
     88        set(originX, MapGridPaintable.ORIGIN_X);
     89        set(originY, MapGridPaintable.ORIGIN_Y);
     90        set(minPixelSpacing, MapGridPaintable.MIN_PIXEL_SPACING);
     91        set(lineWidth, MapGridPaintable.LINE_WIDTH);
     92        lineWidth.setToolTipText(tr("Width of the grid lines in pixels. The colors of the east and north lines are set in the Colors tab."));
     93        minPixelSpacing.setToolTipText(tr("When the grid lines get closer than this on screen, the spacing is multiplied by 10 "
     94                + "so that the grid stays readable when zooming out."));
     95        blendMode.setSelectedItem(MapGridPaintable.BLEND_MODE.get());
     96        blendMode.setRenderer(new TranslatedRenderer<>(GridPreference::blendModeName));
     97        blendMode.setToolTipText(tr("How the lines are combined with the map: normal transparency, multiply (darkens), "
     98                + "burn, hard light, difference (visible on any background) or divide. The colors are set in the Colors tab."));
     99        updateLabels();
     100
     101        panel.add(enabled, GBC.eol().insets(0, 0, 0, 10));
     102        panel.add(new JLabel(tr("Grid type")), GBC.std().insets(5, 0, 5, 5));
     103        panel.add(type, GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 5));
     104        panel.add(spacingXLabel, GBC.std().insets(5, 0, 5, 5));
     105        panel.add(spacingX, GBC.eol().insets(0, 0, 0, 5));
     106        panel.add(spacingYLabel, GBC.std().insets(5, 0, 5, 5));
     107        panel.add(spacingY, GBC.eol().insets(0, 0, 0, 5));
     108        panel.add(new JLabel(tr("Rotation (degrees)")), GBC.std().insets(5, 0, 5, 5));
     109        panel.add(rotation, GBC.eol().insets(0, 0, 0, 5));
     110        panel.add(originXLabel, GBC.std().insets(5, 0, 5, 5));
     111        panel.add(originX, GBC.eol().insets(0, 0, 0, 5));
     112        panel.add(originYLabel, GBC.std().insets(5, 0, 5, 5));
     113        panel.add(originY, GBC.eol().insets(0, 0, 0, 5));
     114        panel.add(new JLabel(tr("Minimum line distance on screen (pixels)")), GBC.std().insets(5, 0, 5, 5));
     115        panel.add(minPixelSpacing, GBC.eol().insets(0, 0, 0, 5));
     116        panel.add(new JLabel(tr("Line width (pixels)")), GBC.std().insets(5, 0, 5, 5));
     117        panel.add(lineWidth, GBC.eol().insets(0, 0, 0, 5));
     118        panel.add(new JLabel(tr("Blend mode")), GBC.std().insets(5, 0, 5, 5));
     119        panel.add(blendMode, GBC.eol().fill(GBC.HORIZONTAL).insets(0, 0, 0, 5));
     120        panel.add(GBC.glue(0, 0), GBC.eol().fill(GBC.BOTH));
     121
     122        createPreferenceTabWithScrollPane(gui, panel);
     123    }
     124
     125    private static String blendModeName(BlendComposite.Mode mode) {
     126        switch (mode) {
     127        case MULTIPLY:
     128            return tr("Multiply");
     129        case BURN:
     130            return tr("Burn");
     131        case HARD_LIGHT:
     132            return tr("Hard light");
     133        case DIFFERENCE:
     134            return tr("Difference");
     135        case DIVIDE:
     136            return tr("Divide");
     137        case NORMAL:
     138        default:
     139            return tr("Normal");
     140        }
     141    }
     142
     143    /** Renders enum values with a translated name */
     144    private static final class TranslatedRenderer<T> extends DefaultListCellRenderer {
     145        private final Function<T, String> name;
     146
     147        TranslatedRenderer(Function<T, String> name) {
     148            this.name = name;
     149        }
     150
     151        @Override
     152        @SuppressWarnings("unchecked")
     153        public Component getListCellRendererComponent(JList<?> list, Object value, int index, boolean isSelected, boolean cellHasFocus) {
     154            String text = value == null ? "" : name.apply((T) value);
     155            return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus);
     156        }
     157    }
     158
     159    private void updateLabels() {
     160        boolean latlon = type.getSelectedItem() != GridType.PROJECTED;
     161        spacingXLabel.setText(latlon ? tr("Longitude spacing (degrees)") : tr("East spacing (metres)"));
     162        spacingYLabel.setText(latlon ? tr("Latitude spacing (degrees)") : tr("North spacing (metres)"));
     163        String spacingTip = latlon
     164                ? tr("Distance between the grid lines in degrees.")
     165                : tr("Distance between the grid lines as a true distance in metres (as measured by the parallel way tool "
     166                        + "and the status bar), measured at the grid origin. Place the origin in the area of interest.");
     167        spacingX.setToolTipText(spacingTip);
     168        spacingY.setToolTipText(spacingTip);
     169        originXLabel.setText(latlon ? tr("Origin longitude") : tr("Origin east"));
     170        originYLabel.setText(latlon ? tr("Origin latitude") : tr("Origin north"));
     171        rotation.setEnabled(!latlon);
     172    }
     173
     174    private static void set(JosmTextField field, DoubleProperty property) {
     175        field.setText(FORMAT.format(property.get()));
     176    }
     177
     178    private static void save(JosmTextField field, DoubleProperty property, boolean positive) {
     179        try {
     180            double value = FORMAT.parse(field.getText().trim()).doubleValue();
     181            if (positive && !(value > 0)) {
     182                Logging.warn("Ignoring invalid grid setting {0}: {1}", property.getKey(), field.getText());
     183                return;
     184            }
     185            property.put(value);
     186        } catch (ParseException e) {
     187            Logging.warn("Ignoring invalid grid setting {0}: {1}", property.getKey(), field.getText());
     188            Logging.trace(e);
     189        }
     190    }
     191
     192    @Override
     193    public boolean ok() {
     194        MapGridPaintable.ENABLED.put(enabled.isSelected());
     195        MapGridPaintable.TYPE.put((GridType) type.getSelectedItem());
     196        save(spacingX, MapGridPaintable.SPACING_X, true);
     197        save(spacingY, MapGridPaintable.SPACING_Y, true);
     198        save(rotation, MapGridPaintable.ROTATION, false);
     199        save(originX, MapGridPaintable.ORIGIN_X, false);
     200        save(originY, MapGridPaintable.ORIGIN_Y, false);
     201        save(minPixelSpacing, MapGridPaintable.MIN_PIXEL_SPACING, true);
     202        save(lineWidth, MapGridPaintable.LINE_WIDTH, true);
     203        MapGridPaintable.BLEND_MODE.put((BlendComposite.Mode) blendMode.getSelectedItem());
     204        return false;
     205    }
     206
     207    @Override
     208    public boolean isExpert() {
     209        return false;
     210    }
     211
     212    @Override
     213    public String getHelpContext() {
     214        return HelpUtil.ht("/Preferences/Grid");
     215    }
     216}
  • new file test/unit/org/openstreetmap/josm/actions/GridActionsTest.java

    diff --git test/unit/org/openstreetmap/josm/actions/GridActionsTest.java test/unit/org/openstreetmap/josm/actions/GridActionsTest.java
    new file mode 100644
    index 0000000000..37e9d667c6
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.actions;
     3
     4import static org.junit.jupiter.api.Assertions.assertEquals;
     5import static org.junit.jupiter.api.Assertions.assertFalse;
     6import static org.junit.jupiter.api.Assertions.assertNull;
     7import static org.junit.jupiter.api.Assertions.assertTrue;
     8
     9import java.awt.event.ActionEvent;
     10
     11import org.junit.jupiter.api.AfterEach;
     12import org.junit.jupiter.api.Test;
     13import org.openstreetmap.josm.data.coor.EastNorth;
     14import org.openstreetmap.josm.data.coor.LatLon;
     15import org.openstreetmap.josm.data.osm.DataSet;
     16import org.openstreetmap.josm.data.osm.Node;
     17import org.openstreetmap.josm.data.osm.Relation;
     18import org.openstreetmap.josm.data.osm.RelationMember;
     19import org.openstreetmap.josm.data.osm.Way;
     20import org.openstreetmap.josm.gui.MainApplication;
     21import org.openstreetmap.josm.gui.layer.MapGridPaintable;
     22import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
     23import org.openstreetmap.josm.gui.layer.OsmDataLayer;
     24import org.openstreetmap.josm.spi.preferences.Config;
     25import org.openstreetmap.josm.testutils.annotations.Main;
     26import org.openstreetmap.josm.testutils.annotations.Projection;
     27
     28/**
     29 * Unit tests of {@link SetGridOriginAction} and {@link AlignGridRotationAction}.
     30 */
     31@Main
     32@Projection
     33class GridActionsTest {
     34
     35    @AfterEach
     36    void reset() {
     37        for (String key : new String[] {"enabled", "type", "rotation", "origin-x", "origin-y"}) {
     38            Config.getPref().put("draw.grid." + key, null);
     39        }
     40    }
     41
     42    /**
     43     * The origin is the position of a single selected node, stored in the coordinates of the grid type, and the
     44     * grid gets enabled. Without a selection the action is disabled.
     45     */
     46    @Test
     47    void testSetOriginSingleNode() {
     48        DataSet ds = new DataSet();
     49        OsmDataLayer layer = new OsmDataLayer(ds, "GridActionsTest", null);
     50        MainApplication.getLayerManager().addLayer(layer);
     51        try {
     52            Node n = new Node(new LatLon(50, 10));
     53            ds.addPrimitive(n);
     54
     55            assertFalse(new SetGridOriginAction().isEnabled());
     56
     57            ds.setSelected(n);
     58            EastNorth en = n.getEastNorth();
     59            MapGridPaintable.TYPE.put(GridType.LATLON);
     60            SetGridOriginAction action = new SetGridOriginAction();
     61            assertTrue(action.isEnabled());
     62            action.actionPerformed(new ActionEvent(this, 0, ""));
     63            assertEquals(10, MapGridPaintable.ORIGIN_X.get(), 1e-7);
     64            assertEquals(50, MapGridPaintable.ORIGIN_Y.get(), 1e-7);
     65            assertTrue(MapGridPaintable.ENABLED.get());
     66
     67            MapGridPaintable.TYPE.put(GridType.PROJECTED);
     68            action.actionPerformed(new ActionEvent(this, 0, ""));
     69            assertEquals(en.east(), MapGridPaintable.ORIGIN_X.get(), 1e-6);
     70            assertEquals(en.north(), MapGridPaintable.ORIGIN_Y.get(), 1e-6);
     71        } finally {
     72            MainApplication.getLayerManager().removeLayer(layer);
     73        }
     74    }
     75
     76    /**
     77     * With more than one node reachable from the selection, the origin is the arithmetic mean of their positions:
     78     * the centroid of two nodes of a way is their midpoint, not weighted by any polygon area.
     79     */
     80    @Test
     81    void testSetOriginCentroid() {
     82        DataSet ds = new DataSet();
     83        OsmDataLayer layer = new OsmDataLayer(ds, "GridActionsTest", null);
     84        MainApplication.getLayerManager().addLayer(layer);
     85        try {
     86            Node a = new Node(new EastNorth(0, 0));
     87            Node b = new Node(new EastNorth(100, 0));
     88            Way w = new Way();
     89            w.addNode(a);
     90            w.addNode(b);
     91            ds.addPrimitive(a);
     92            ds.addPrimitive(b);
     93            ds.addPrimitive(w);
     94
     95            ds.setSelected(w);
     96            MapGridPaintable.TYPE.put(GridType.PROJECTED);
     97            SetGridOriginAction action = new SetGridOriginAction();
     98            assertTrue(action.isEnabled());
     99            action.actionPerformed(new ActionEvent(this, 0, ""));
     100            assertEquals(50, MapGridPaintable.ORIGIN_X.get(), 1e-6);
     101            assertEquals(0, MapGridPaintable.ORIGIN_Y.get(), 1e-6);
     102
     103            // a relation contributes its node members
     104            Node c = new Node(new EastNorth(0, 100));
     105            ds.addPrimitive(c);
     106            Relation r = new Relation();
     107            r.addMember(new RelationMember("", c));
     108            ds.addPrimitive(r);
     109            ds.setSelected(r);
     110            action = new SetGridOriginAction();
     111            assertTrue(action.isEnabled());
     112            action.actionPerformed(new ActionEvent(this, 0, ""));
     113            assertEquals(0, MapGridPaintable.ORIGIN_X.get(), 1e-6);
     114            assertEquals(100, MapGridPaintable.ORIGIN_Y.get(), 1e-6);
     115
     116            ds.setSelected();
     117            assertNull(SetGridOriginAction.getCentroid(ds));
     118            assertFalse(new SetGridOriginAction().isEnabled());
     119        } finally {
     120            MainApplication.getLayerManager().removeLayer(layer);
     121        }
     122    }
     123
     124    /**
     125     * The rotation is reduced to [0, 90) and is the same for all four directions of a square grid
     126     */
     127    @Test
     128    void testRotationOf() {
     129        EastNorth o = new EastNorth(0, 0);
     130        assertEquals(0, AlignGridRotationAction.rotationOf(o, new EastNorth(10, 0)), 1e-9);
     131        assertEquals(0, AlignGridRotationAction.rotationOf(o, new EastNorth(0, 10)), 1e-9);
     132        assertEquals(0, AlignGridRotationAction.rotationOf(o, new EastNorth(-10, 0)), 1e-9);
     133        assertEquals(45, AlignGridRotationAction.rotationOf(o, new EastNorth(10, 10)), 1e-9);
     134        assertEquals(45, AlignGridRotationAction.rotationOf(o, new EastNorth(-10, 10)), 1e-9);
     135        double c30 = Math.cos(Math.toRadians(30));
     136        double s30 = Math.sin(Math.toRadians(30));
     137        assertEquals(30, AlignGridRotationAction.rotationOf(o, new EastNorth(c30, s30)), 1e-9);
     138        assertEquals(30, AlignGridRotationAction.rotationOf(o, new EastNorth(-c30, -s30)), 1e-9);
     139        // a road at compass heading 115 degrees is a grid rotated by 65 degrees
     140        double heading = Math.toRadians(115);
     141        assertEquals(65, AlignGridRotationAction.rotationOf(o, new EastNorth(Math.sin(heading), Math.cos(heading))), 1e-9);
     142    }
     143
     144    /**
     145     * The direction comes from a single selected way or two selected nodes; anything else disables the action.
     146     * The action reacts live to selection changes, since it now lives in a persistent menu.
     147     */
     148    @Test
     149    void testAlignToSelection() {
     150        DataSet ds = new DataSet();
     151        OsmDataLayer layer = new OsmDataLayer(ds, "GridActionsTest", null);
     152        MainApplication.getLayerManager().addLayer(layer);
     153        try {
     154            Node a = new Node(new LatLon(50, 10));
     155            Node b = new Node(new LatLon(50.01, 10.01));
     156            Node c = new Node(new LatLon(50.02, 10));
     157            Way w = new Way();
     158            w.addNode(a);
     159            w.addNode(c);
     160            w.addNode(b);
     161            ds.addPrimitive(a);
     162            ds.addPrimitive(b);
     163            ds.addPrimitive(c);
     164            ds.addPrimitive(w);
     165
     166            ds.setSelected();
     167            assertNull(AlignGridRotationAction.getSelectedDirection(ds));
     168            AlignGridRotationAction action = new AlignGridRotationAction();
     169            assertFalse(action.isEnabled());
     170            ds.setSelected(a);
     171            assertNull(AlignGridRotationAction.getSelectedDirection(ds));
     172            assertFalse(action.isEnabled());
     173            ds.setSelected(a, b, c);
     174            assertNull(AlignGridRotationAction.getSelectedDirection(ds));
     175            assertFalse(action.isEnabled());
     176
     177            ds.setSelected(a, b);
     178            assertTrue(action.isEnabled());
     179            MapGridPaintable.TYPE.put(GridType.LATLON);
     180            action.actionPerformed(new ActionEvent(this, 0, ""));
     181            double expected = AlignGridRotationAction.rotationOf(a.getEastNorth(), b.getEastNorth());
     182            assertEquals(expected, MapGridPaintable.ROTATION.get(), 1e-9);
     183            assertEquals(GridType.PROJECTED, MapGridPaintable.TYPE.get());
     184            assertTrue(MapGridPaintable.ENABLED.get());
     185
     186            // the way: first to last node (a to b), not the first segment
     187            ds.setSelected(w);
     188            assertTrue(action.isEnabled());
     189            EastNorth[] dir = AlignGridRotationAction.getSelectedDirection(ds);
     190            assertEquals(a.getEastNorth(), dir[0]);
     191            assertEquals(b.getEastNorth(), dir[1]);
     192        } finally {
     193            MainApplication.getLayerManager().removeLayer(layer);
     194        }
     195    }
     196}
  • new file test/unit/org/openstreetmap/josm/gui/draw/BlendCompositeTest.java

    diff --git test/unit/org/openstreetmap/josm/gui/draw/BlendCompositeTest.java test/unit/org/openstreetmap/josm/gui/draw/BlendCompositeTest.java
    new file mode 100644
    index 0000000000..9498b08f82
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.draw;
     3
     4import static org.junit.jupiter.api.Assertions.assertEquals;
     5import static org.junit.jupiter.api.Assertions.assertSame;
     6
     7import java.awt.Color;
     8import java.awt.Graphics2D;
     9import java.awt.image.BufferedImage;
     10
     11import org.junit.jupiter.params.ParameterizedTest;
     12import org.junit.jupiter.params.provider.ValueSource;
     13import org.junit.jupiter.api.Test;
     14import org.openstreetmap.josm.gui.draw.BlendComposite.Mode;
     15
     16/**
     17 * Unit tests of {@link BlendComposite}.
     18 */
     19class BlendCompositeTest {
     20
     21    private static int paint(int imageType, Color background, Color paint, Mode mode) {
     22        BufferedImage img = new BufferedImage(4, 4, imageType);
     23        Graphics2D g = img.createGraphics();
     24        g.setColor(background);
     25        g.fillRect(0, 0, 4, 4);
     26        g.setComposite(BlendComposite.getInstance(mode));
     27        g.setColor(paint);
     28        g.fillRect(1, 1, 2, 2);
     29        g.dispose();
     30        // the untouched pixel keeps the background
     31        assertEquals(background.getRGB() & 0xffffff, img.getRGB(0, 0) & 0xffffff);
     32        return img.getRGB(1, 1) & 0xffffff;
     33    }
     34
     35    /**
     36     * Blend modes on an opaque source, on both an image without and with alpha channel.
     37     * @param imageType image type
     38     */
     39    @ParameterizedTest
     40    @ValueSource(ints = {BufferedImage.TYPE_3BYTE_BGR, BufferedImage.TYPE_INT_RGB, BufferedImage.TYPE_INT_ARGB})
     41    void testModes(int imageType) {
     42        Color bg = new Color(200, 100, 50);
     43        Color fg = new Color(128, 255, 0);
     44        assertEquals(0x80ff00, paint(imageType, bg, fg, Mode.NORMAL));
     45        assertEquals(new Color(200 * 128 / 255, 100, 0).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.MULTIPLY));
     46        assertEquals(new Color(146, 100, 0).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.BURN));
     47        assertEquals(new Color(201, 255, 0).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.HARD_LIGHT));
     48        assertEquals(new Color(72, 155, 50).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.DIFFERENCE));
     49        assertEquals(new Color(255, 100, 255).getRGB() & 0xffffff, paint(imageType, bg, fg, Mode.DIVIDE));
     50    }
     51
     52    /**
     53     * A translucent source only partially applies the blended color.
     54     */
     55    @Test
     56    void testAlpha() {
     57        Color bg = new Color(200, 200, 200);
     58        // half transparent black, multiply: 200 -> 0 at full alpha -> 100 at half alpha
     59        int rgb = paint(BufferedImage.TYPE_INT_RGB, bg, new Color(0, 0, 0, 128), Mode.MULTIPLY);
     60        assertEquals(100, (rgb >> 16) & 0xff, 1);
     61        assertEquals(100, rgb & 0xff, 1);
     62        // fully transparent: nothing changes
     63        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, new Color(0, 0, 0, 0), Mode.DIFFERENCE));
     64    }
     65
     66    /**
     67     * White is neutral for multiply, burn and divide; black is neutral for difference
     68     */
     69    @Test
     70    void testNeutralColors() {
     71        Color bg = new Color(12, 34, 56);
     72        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.WHITE, Mode.MULTIPLY));
     73        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.WHITE, Mode.BURN));
     74        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.WHITE, Mode.DIVIDE));
     75        assertEquals(bg.getRGB() & 0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.BLACK, Mode.DIFFERENCE));
     76        // black burns everything to black, white hard light gives white
     77        assertEquals(0, paint(BufferedImage.TYPE_INT_RGB, bg, Color.BLACK, Mode.BURN));
     78        assertEquals(0xffffff, paint(BufferedImage.TYPE_INT_RGB, bg, Color.WHITE, Mode.HARD_LIGHT));
     79    }
     80
     81    /**
     82     * A translucent destination is composed with the "source over" rule: the result alpha is
     83     * {@code as + ab*(1-as)} and the blend only applies where the backdrop is actually present.
     84     */
     85    @Test
     86    void testTranslucentDestination() {
     87        // opaque source over a half transparent destination: the source wins, the result is opaque
     88        int argb = BlendComposite.composePixel(Mode.MULTIPLY, 0xff808080, 0x80ffffff);
     89        assertEquals(0xff, argb >>> 24);
     90        assertEquals(0x80, (argb >> 16) & 0xff, 1);
     91
     92        // half transparent source over a fully transparent destination: the source shows unblended
     93        argb = BlendComposite.composePixel(Mode.MULTIPLY, 0x80123456, 0x00000000);
     94        assertEquals(0x80, argb >>> 24);
     95        assertEquals(0x12, (argb >> 16) & 0xff, 1);
     96        assertEquals(0x34, (argb >> 8) & 0xff, 1);
     97        assertEquals(0x56, argb & 0xff, 1);
     98
     99        // half transparent source over a half transparent destination: alpha is 128 + 128*(1-128/255)
     100        argb = BlendComposite.composePixel(Mode.NORMAL, 0x80ffffff, 0x80000000);
     101        assertEquals(128 + 128 * (255 - 128) / 255, argb >>> 24);
     102
     103        // a transparent source never changes the destination, whatever the mode
     104        for (Mode mode : Mode.values()) {
     105            assertEquals(0x8012ab34, BlendComposite.composePixel(mode, 0x00ffffff, 0x8012ab34), mode::toString);
     106        }
     107
     108        // an opaque destination keeps its alpha and is only moved towards the blended color
     109        assertEquals(0xff000000, BlendComposite.composePixel(Mode.MULTIPLY, 0xff000000, 0xffffffff));
     110    }
     111
     112    /**
     113     * Instances are shared per mode
     114     */
     115    @Test
     116    void testInstances() {
     117        assertSame(BlendComposite.getInstance(Mode.MULTIPLY), BlendComposite.getInstance(Mode.MULTIPLY));
     118        assertEquals(Mode.DIVIDE, BlendComposite.getInstance(Mode.DIVIDE).getMode());
     119    }
     120}
  • new file test/unit/org/openstreetmap/josm/gui/layer/MapGridPaintableTest.java

    diff --git test/unit/org/openstreetmap/josm/gui/layer/MapGridPaintableTest.java test/unit/org/openstreetmap/josm/gui/layer/MapGridPaintableTest.java
    new file mode 100644
    index 0000000000..dc99e73d39
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer;
     3
     4import static org.junit.jupiter.api.Assertions.assertEquals;
     5import static org.junit.jupiter.api.Assertions.assertTrue;
     6
     7import java.awt.Color;
     8import java.awt.Point;
     9import java.awt.Rectangle;
     10import java.awt.image.BufferedImage;
     11import java.util.List;
     12import java.util.stream.Collectors;
     13
     14import org.junit.jupiter.api.Test;
     15import org.openstreetmap.josm.data.Bounds;
     16import org.openstreetmap.josm.data.ProjectionBounds;
     17import org.openstreetmap.josm.data.coor.EastNorth;
     18import org.openstreetmap.josm.data.coor.LatLon;
     19import org.openstreetmap.josm.data.projection.ProjectionRegistry;
     20import org.openstreetmap.josm.gui.MainApplication;
     21import org.openstreetmap.josm.gui.MapView;
     22import org.openstreetmap.josm.gui.draw.BlendComposite;
     23import org.openstreetmap.josm.gui.layer.MapGridPaintable.GridType;
     24import org.openstreetmap.josm.gui.layer.MapGridPaintable.LatLonGridLine;
     25import org.openstreetmap.josm.gui.layer.MapGridPaintable.ProjectedGridLine;
     26import org.openstreetmap.josm.gui.util.GuiHelper;
     27import org.openstreetmap.josm.testutils.annotations.Main;
     28import org.openstreetmap.josm.testutils.annotations.Projection;
     29
     30/**
     31 * Unit tests of {@link MapGridPaintable}.
     32 */
     33@Main
     34@Projection
     35class MapGridPaintableTest {
     36
     37    private static long count(List<ProjectedGridLine> lines, boolean vertical) {
     38        return lines.stream().filter(l -> vertical == (Math.abs(l.start.east() - l.end.east()) < 1e-9))
     39                .peek(l -> assertEquals(vertical, l.constantEast, "wrong direction flag: " + l)).count();
     40    }
     41
     42    /**
     43     * An axis aligned grid covers the area with lines at multiples of the spacing.
     44     */
     45    @Test
     46    void testProjectedGridAxisAligned() {
     47        ProjectionBounds area = new ProjectionBounds(new EastNorth(-250, -120), new EastNorth(1010, 380));
     48        List<ProjectedGridLine> lines = MapGridPaintable.getProjectedGridLines(area, 100, 50, 0, 0, 0);
     49        // vertical lines at -200 .. 1000 (13), horizontal at -100 .. 350 (10)
     50        assertEquals(13, count(lines, true));
     51        assertEquals(10, count(lines, false));
     52        assertEquals(23, lines.size());
     53        for (ProjectedGridLine line : lines) {
     54            if (line.constantEast) {
     55                assertEquals(0, line.start.east() % 100, 1e-9, "vertical line not on the grid: " + line);
     56                assertEquals(area.minNorth, Math.min(line.start.north(), line.end.north()), 1e-9);
     57                assertEquals(area.maxNorth, Math.max(line.start.north(), line.end.north()), 1e-9);
     58            } else {
     59                assertEquals(0, line.start.north() % 50, 1e-9, "horizontal line not on the grid: " + line);
     60                assertEquals(area.minEast, Math.min(line.start.east(), line.end.east()), 1e-9);
     61                assertEquals(area.maxEast, Math.max(line.start.east(), line.end.east()), 1e-9);
     62            }
     63        }
     64    }
     65
     66    /**
     67     * The origin shifts the grid, the rotation turns it around the origin.
     68     */
     69    @Test
     70    void testProjectedGridOriginAndRotation() {
     71        ProjectionBounds area = new ProjectionBounds(new EastNorth(0, 0), new EastNorth(100, 100));
     72        List<ProjectedGridLine> lines = MapGridPaintable.getProjectedGridLines(area, 30, 30, 0, 5, 10);
     73        // vertical lines at 5, 35, 65, 95; horizontal at 10, 40, 70, 100
     74        assertEquals(4, count(lines, true));
     75        assertEquals(4, count(lines, false));
     76        assertTrue(lines.stream().anyMatch(l -> Math.abs(l.start.east() - 95) < 1e-9 && Math.abs(l.end.east() - 95) < 1e-9));
     77        assertTrue(lines.stream().anyMatch(l -> Math.abs(l.start.north() - 100) < 1e-9 && Math.abs(l.end.north() - 100) < 1e-9));
     78
     79        lines = MapGridPaintable.getProjectedGridLines(area, 30, 30, 45, 0, 0);
     80        assertTrue(lines.size() > 4, lines.toString());
     81        for (ProjectedGridLine line : lines) {
     82            double dx = line.end.east() - line.start.east();
     83            double dy = line.end.north() - line.start.north();
     84            // every line runs at +45° or -45°
     85            assertEquals(Math.abs(dx), Math.abs(dy), 1e-6, "line not rotated by 45°: " + line);
     86            // the lines pass through the grid points (u, v) = (k*30, m*30) rotated by 45°: check the distance of the
     87            // origin to the line is a multiple of 30
     88            double len = Math.hypot(dx, dy);
     89            double dist = Math.abs(dx * (0 - line.start.north()) - dy * (0 - line.start.east())) / len;
     90            assertEquals(0, dist % 30 < 1e-6 ? 0 : Math.abs(dist % 30 - 30), 1e-6, "line not on the rotated grid: " + dist);
     91        }
     92    }
     93
     94    /**
     95     * Invalid spacings and huge line counts yield nothing.
     96     */
     97    @Test
     98    void testProjectedGridLimits() {
     99        ProjectionBounds area = new ProjectionBounds(new EastNorth(0, 0), new EastNorth(100, 100));
     100        assertTrue(MapGridPaintable.getProjectedGridLines(area, 0, 10, 0, 0, 0).isEmpty());
     101        assertTrue(MapGridPaintable.getProjectedGridLines(area, 10, -1, 0, 0, 0).isEmpty());
     102        assertTrue(MapGridPaintable.getProjectedGridLines(area, 0.01, 0.01, 0, 0, 0).isEmpty());
     103    }
     104
     105    /**
     106     * Latitude/longitude lines: multiples of the spacing (shifted by the origin), clamped to the world, curved
     107     * lines with the requested number of segments.
     108     */
     109    @Test
     110    void testLatLonGrid() {
     111        Bounds world = new Bounds(-85, -180, 85, 180);
     112        Bounds area = new Bounds(50.2, 7.9, 52.1, 10.3);
     113        List<LatLonGridLine> gridLines = MapGridPaintable.getLatLonGridLines(area, world, 1, 0.5, 0, 0, 4);
     114        // meridians 8, 9, 10; parallels 50.5, 51, 51.5, 52
     115        assertEquals(7, gridLines.size());
     116        assertEquals(3, gridLines.stream().filter(l -> l.meridian).count());
     117        for (LatLonGridLine gridLine : gridLines) {
     118            List<LatLon> line = gridLine.points;
     119            assertEquals(5, line.size());
     120            assertEquals(gridLine.meridian, line.get(0).lon() == line.get(4).lon());
     121            if (gridLine.meridian) {
     122                assertEquals(0, line.get(0).lon() % 1, 1e-9);
     123                assertEquals(50.2, line.get(0).lat(), 1e-9);
     124                assertEquals(52.1, line.get(4).lat(), 1e-9);
     125            } else {
     126                assertEquals(0, line.get(0).lat() % 0.5, 1e-9);
     127                assertEquals(7.9, line.get(0).lon(), 1e-9);
     128                assertEquals(10.3, line.get(4).lon(), 1e-9);
     129            }
     130        }
     131        // origin offset
     132        List<List<LatLon>> lines = points(MapGridPaintable.getLatLonGridLines(area, world, 1, 1, 0.5, 0.25, 1));
     133        assertTrue(lines.stream().anyMatch(l -> l.get(0).lon() == 8.5 && l.get(1).lon() == 8.5), lines.toString());
     134        assertTrue(lines.stream().anyMatch(l -> l.get(0).lat() == 51.25 && l.get(1).lat() == 51.25), lines.toString());
     135
     136        // clamped to the world bounds: no parallel at 90
     137        lines = points(MapGridPaintable.getLatLonGridLines(new Bounds(80, 0, 89.9, 10), world, 10, 10, 0, 0, 1));
     138        assertTrue(lines.stream().noneMatch(l -> l.get(0).lat() > 85), lines.toString());
     139        assertTrue(lines.stream().anyMatch(l -> l.get(0).lat() == 80 && l.get(1).lat() == 80), lines.toString());
     140
     141        // across the antimeridian: meridians 170 .. 180, -170; longitudes stay in [-180, 180]
     142        gridLines = MapGridPaintable.getLatLonGridLines(new Bounds(0, 165, 10, -165), world, 10, 90, 0, 0, 1);
     143        assertEquals(3, gridLines.stream().filter(l -> l.meridian).count(), gridLines.toString());
     144        assertTrue(gridLines.stream().allMatch(l -> l.points.stream().allMatch(ll -> ll.lon() >= -180 && ll.lon() <= 180)));
     145    }
     146
     147    /**
     148     * A projection which does not span the whole globe clamps the meridians to its longitude range, the same way
     149     * the parallels are clamped to its latitude range.
     150     */
     151    @Test
     152    void testLatLonGridLimitedWorld() {
     153        // a projection valid for 6 degrees of longitude only, e.g. a UTM zone
     154        Bounds world = new Bounds(0, 6, 84, 12);
     155        Bounds area = new Bounds(45, 2, 50, 16);
     156        List<LatLonGridLine> lines = MapGridPaintable.getLatLonGridLines(area, world, 2, 2, 0, 0, 1);
     157        assertTrue(lines.stream().filter(l -> l.meridian).findAny().isPresent(), lines.toString());
     158        assertTrue(lines.stream().filter(l -> l.meridian)
     159                .allMatch(l -> l.points.get(0).lon() >= 6 && l.points.get(0).lon() <= 12), lines.toString());
     160        // meridians 6, 8, 10, 12 are inside the projection, 2, 4, 14, 16 are not
     161        assertEquals(4, lines.stream().filter(l -> l.meridian).count(), lines.toString());
     162
     163        // a world spanning all longitudes keeps every meridian, including across the antimeridian
     164        Bounds whole = new Bounds(-85, -180, 85, 180);
     165        assertEquals(8, MapGridPaintable.getLatLonGridLines(area, whole, 2, 2, 0, 0, 1)
     166                .stream().filter(l -> l.meridian).count());
     167    }
     168
     169    private static List<List<LatLon>> points(List<LatLonGridLine> lines) {
     170        return lines.stream().map(l -> l.points).collect(Collectors.toList());
     171    }
     172
     173    /**
     174     * In Mercator one metre is 1/cos(latitude) projection units.
     175     */
     176    @Test
     177    void testProjectionUnitsPerMetre() {
     178        org.openstreetmap.josm.data.projection.Projection proj = ProjectionRegistry.getProjection();
     179        assertEquals(1, MapGridPaintable.projectionUnitsPerMetre(proj, proj.latlon2eastNorth(new LatLon(0, 10))), 1e-3);
     180        assertEquals(1 / Math.cos(Math.toRadians(50)),
     181                MapGridPaintable.projectionUnitsPerMetre(proj, proj.latlon2eastNorth(new LatLon(50, 10))), 1e-3);
     182        assertEquals(1 / Math.cos(Math.toRadians(20.6)),
     183                MapGridPaintable.projectionUnitsPerMetre(proj, proj.latlon2eastNorth(new LatLon(20.6, 87.8))), 1e-3);
     184        // outside the world: no scaling
     185        assertEquals(1, MapGridPaintable.projectionUnitsPerMetre(proj, new EastNorth(0, 1e12)), 1e-9);
     186    }
     187
     188    /**
     189     * The thinning factor is a power of ten bringing the spacing above the minimum.
     190     */
     191    @Test
     192    void testThinningFactor() {
     193        MapGridPaintable.MIN_PIXEL_SPACING.put(25.0);
     194        try {
     195            assertEquals(1, MapGridPaintable.thinningFactor(30));
     196            assertEquals(1, MapGridPaintable.thinningFactor(25));
     197            assertEquals(10, MapGridPaintable.thinningFactor(24.9));
     198            assertEquals(100, MapGridPaintable.thinningFactor(0.3));
     199            assertEquals(1, MapGridPaintable.thinningFactor(0));
     200            assertEquals(1, MapGridPaintable.thinningFactor(Double.NaN));
     201        } finally {
     202            MapGridPaintable.MIN_PIXEL_SPACING.remove();
     203        }
     204    }
     205
     206    /**
     207     * Painting on a map view draws something when enabled and nothing when disabled, for both grid types and
     208     * with a blend mode.
     209     */
     210    @Test
     211    void testPaint() {
     212        SizedMapView mv = new SizedMapView();
     213        mv.setBounds(new Rectangle(400, 300));
     214        GuiHelper.runInEDTAndWait(() -> { /* let the component listener update the view state */ });
     215        mv.updateState();
     216        mv.zoomTo(new LatLon(50, 10));
     217        assertTrue(mv.getRealBounds().getMaxLat() > mv.getRealBounds().getMinLat(), "map view has no size: " + mv.getRealBounds());
     218        MapGridPaintable grid = new MapGridPaintable();
     219        try {
     220            MapGridPaintable.ENABLED.put(false);
     221            assertEquals(0, paintedPixels(grid, mv), "grid drawn although disabled");
     222            MapGridPaintable.ENABLED.put(true);
     223            MapGridPaintable.TYPE.put(GridType.LATLON);
     224            MapGridPaintable.SPACING_X.put(0.001);
     225            MapGridPaintable.SPACING_Y.put(0.001);
     226            assertTrue(paintedPixels(grid, mv) > 100, "lat/lon grid not drawn");
     227            MapGridPaintable.TYPE.put(GridType.PROJECTED);
     228            MapGridPaintable.SPACING_X.put(100.0);
     229            MapGridPaintable.SPACING_Y.put(100.0);
     230            MapGridPaintable.ROTATION.put(30.0);
     231            MapGridPaintable.BLEND_MODE.put(BlendComposite.Mode.MULTIPLY);
     232            assertTrue(paintedPixels(grid, mv) > 100, "projected grid not drawn");
     233        } finally {
     234            grid.destroy();
     235            mv.destroy();
     236            for (String key : new String[] {"enabled", "type", "spacing-x", "spacing-y", "rotation", "blend-mode"}) {
     237                org.openstreetmap.josm.spi.preferences.Config.getPref().put("draw.grid." + key, null);
     238            }
     239        }
     240    }
     241
     242    /** A map view which believes it is shown on screen, so that its view state gets a size */
     243    private static final class SizedMapView extends MapView {
     244        SizedMapView() {
     245            super(MainApplication.getLayerManager(), null);
     246        }
     247
     248        @Override
     249        public Point getLocationOnScreen() {
     250            return new Point(0, 0);
     251        }
     252
     253        @Override
     254        protected boolean isVisibleOnScreen() {
     255            return true;
     256        }
     257
     258        void updateState() {
     259            updateLocationState();
     260        }
     261    }
     262
     263    private static int paintedPixels(MapGridPaintable grid, MapView mv) {
     264        BufferedImage img = new BufferedImage(mv.getWidth(), mv.getHeight(), BufferedImage.TYPE_3BYTE_BGR);
     265        java.awt.Graphics2D g = img.createGraphics();
     266        g.setColor(Color.WHITE);
     267        g.fillRect(0, 0, img.getWidth(), img.getHeight());
     268        grid.paint(g, mv, mv.getRealBounds());
     269        g.dispose();
     270        int count = 0;
     271        for (int y = 0; y < img.getHeight(); y++) {
     272            for (int x = 0; x < img.getWidth(); x++) {
     273                if ((img.getRGB(x, y) & 0xffffff) != 0xffffff) {
     274                    count++;
     275                }
     276            }
     277        }
     278        return count;
     279    }
     280}
  • new file test/unit/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersActionTest.java

    diff --git test/unit/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersActionTest.java test/unit/org/openstreetmap/josm/gui/layer/imagery/ShowTileBordersActionTest.java
    new file mode 100644
    index 0000000000..45c30cb2a0
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.imagery;
     3
     4import static org.junit.jupiter.api.Assertions.assertFalse;
     5import static org.junit.jupiter.api.Assertions.assertTrue;
     6
     7import java.awt.event.ActionEvent;
     8import java.util.Collections;
     9import java.util.HashMap;
     10import java.util.Map;
     11
     12import javax.swing.JCheckBoxMenuItem;
     13
     14import org.junit.jupiter.api.AfterEach;
     15import org.junit.jupiter.api.Test;
     16import org.openstreetmap.josm.gui.layer.TMSLayer;
     17import org.openstreetmap.josm.gui.layer.TMSLayerTest;
     18import org.openstreetmap.josm.spi.preferences.Config;
     19import org.openstreetmap.josm.testutils.annotations.Main;
     20import org.openstreetmap.josm.testutils.annotations.Projection;
     21
     22/**
     23 * Unit tests of {@link ShowTileBordersAction}.
     24 */
     25@Main
     26@Projection
     27class ShowTileBordersActionTest {
     28
     29    @AfterEach
     30    void resetPreferences() {
     31        Config.getPref().put("imagery.generic.default_showtileborders", null);
     32    }
     33
     34    /**
     35     * Toggling the tile borders remembers the choice, so that a layer created later - in this session or after
     36     * a restart - shows them too. Non-regression test for the setting being forgotten between sessions.
     37     */
     38    @Test
     39    void testTogglePersistsTheChoice() {
     40        TMSLayer layer = TMSLayerTest.createTmsLayer();
     41        assertFalse(layer.getDisplaySettings().isShowTileBorders());
     42        ShowTileBordersAction action = new ShowTileBordersAction(layer);
     43
     44        action.actionPerformed(new ActionEvent(this, 0, ""));
     45        assertTrue(layer.getDisplaySettings().isShowTileBorders());
     46        assertTrue(TileSourceDisplaySettings.PROP_SHOW_TILE_BORDERS.get());
     47        // a layer created afterwards picks the choice up, which is what happens after a restart
     48        assertTrue(TMSLayerTest.createTmsLayer().getDisplaySettings().isShowTileBorders());
     49        assertTrue(new JCheckBoxMenuItem(action).getModel().isEnabled());
     50        assertTrue(((JCheckBoxMenuItem) action.createMenuComponent()).isSelected());
     51
     52        action.actionPerformed(new ActionEvent(this, 0, ""));
     53        assertFalse(layer.getDisplaySettings().isShowTileBorders());
     54        assertFalse(TileSourceDisplaySettings.PROP_SHOW_TILE_BORDERS.get());
     55        assertFalse(TMSLayerTest.createTmsLayer().getDisplaySettings().isShowTileBorders());
     56        assertFalse(((JCheckBoxMenuItem) action.createMenuComponent()).isSelected());
     57    }
     58
     59    /**
     60     * Loading a session applies the setting to that layer only, it must not change the remembered default.
     61     */
     62    @Test
     63    void testSessionDoesNotOverwriteThePreference() {
     64        TMSLayer layer = TMSLayerTest.createTmsLayer();
     65        Map<String, String> session = new HashMap<>(Collections.singletonMap("show-tile-borders", "true"));
     66        layer.getDisplaySettings().applyFromPropertiesMap(session);
     67
     68        assertTrue(layer.getDisplaySettings().isShowTileBorders());
     69        assertFalse(TileSourceDisplaySettings.PROP_SHOW_TILE_BORDERS.get());
     70        assertFalse(TMSLayerTest.createTmsLayer().getDisplaySettings().isShowTileBorders());
     71    }
     72}
  • new file test/unit/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettingsTest.java

    diff --git test/unit/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettingsTest.java test/unit/org/openstreetmap/josm/gui/layer/imagery/TileSourceDisplaySettingsTest.java
    new file mode 100644
    index 0000000000..6adbe9eaaa
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.layer.imagery;
     3
     4import static org.junit.jupiter.api.Assertions.assertEquals;
     5import static org.junit.jupiter.api.Assertions.assertFalse;
     6import static org.junit.jupiter.api.Assertions.assertNotEquals;
     7import static org.junit.jupiter.api.Assertions.assertTrue;
     8
     9import java.util.ArrayList;
     10import java.util.List;
     11import java.util.Map;
     12
     13import org.junit.jupiter.api.AfterEach;
     14import org.junit.jupiter.api.Test;
     15import org.openstreetmap.josm.spi.preferences.Config;
     16import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
     17
     18/**
     19 * Unit tests of {@link TileSourceDisplaySettings}.
     20 */
     21@BasicPreferences
     22class TileSourceDisplaySettingsTest {
     23
     24    @AfterEach
     25    void resetPreferences() {
     26        Config.getPref().put("imagery.generic.default_showtileborders", null);
     27        Config.getPref().put("imagery.tms.default_showtileborders", null);
     28    }
     29
     30    /**
     31     * The tile borders setting fires a change event and survives a round trip through the session properties.
     32     */
     33    @Test
     34    void testShowTileBorders() {
     35        TileSourceDisplaySettings settings = new TileSourceDisplaySettings();
     36        assertFalse(settings.isShowTileBorders());
     37        List<String> changes = new ArrayList<>();
     38        settings.addSettingsChangeListener(e -> changes.add(e.getChangedSetting()));
     39        settings.setShowTileBorders(true);
     40        assertTrue(settings.isShowTileBorders());
     41        assertEquals(1, changes.size());
     42
     43        Map<String, String> data = settings.toPropertiesMap();
     44        assertEquals("true", data.get("show-tile-borders"));
     45        TileSourceDisplaySettings copy = new TileSourceDisplaySettings();
     46        assertNotEquals(settings, copy);
     47        copy.applyFromPropertiesMap(data);
     48        assertTrue(copy.isShowTileBorders());
     49        assertEquals(settings, copy);
     50        assertEquals(settings.hashCode(), copy.hashCode());
     51
     52        // a session without the setting keeps the current value
     53        data.remove("show-tile-borders");
     54        copy.applyFromPropertiesMap(data);
     55        assertTrue(copy.isShowTileBorders());
     56    }
     57
     58    /**
     59     * The default of a new layer comes from the preferences, read with the same prefix logic as the sibling
     60     * settings (auto load, auto zoom, show errors): an explicitly set value is honoured, and a layer specific
     61     * prefix takes precedence over the generic one.
     62     */
     63    @Test
     64    void testShowTileBordersDefault() {
     65        assertFalse(new TileSourceDisplaySettings().isShowTileBorders());
     66        assertFalse(new TileSourceDisplaySettings("imagery.tms").isShowTileBorders());
     67
     68        Config.getPref().putBoolean("imagery.generic.default_showtileborders", true);
     69        assertTrue(new TileSourceDisplaySettings().isShowTileBorders());
     70
     71        Config.getPref().putBoolean("imagery.tms.default_showtileborders", true);
     72        assertTrue(new TileSourceDisplaySettings("imagery.tms").isShowTileBorders());
     73
     74        Config.getPref().putBoolean("imagery.tms.default_showtileborders", false);
     75        assertFalse(new TileSourceDisplaySettings("imagery.tms").isShowTileBorders());
     76    }
     77}
  • new file test/unit/org/openstreetmap/josm/gui/preferences/display/GridPreferenceTest.java

    diff --git test/unit/org/openstreetmap/josm/gui/preferences/display/GridPreferenceTest.java test/unit/org/openstreetmap/josm/gui/preferences/display/GridPreferenceTest.java
    new file mode 100644
    index 0000000000..b52d26c915
    - +  
     1// License: GPL. For details, see LICENSE file.
     2package org.openstreetmap.josm.gui.preferences.display;
     3
     4import static org.junit.jupiter.api.Assertions.assertEquals;
     5import static org.junit.jupiter.api.Assertions.assertNotNull;
     6
     7import org.junit.jupiter.api.Test;
     8import org.openstreetmap.josm.gui.layer.MapGridPaintable;
     9import org.openstreetmap.josm.gui.preferences.PreferencesTestUtils;
     10import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
     11import org.openstreetmap.josm.testutils.annotations.Main;
     12
     13/**
     14 * Unit tests of {@link GridPreference} class.
     15 */
     16@BasicPreferences
     17@Main
     18class GridPreferenceTest {
     19    /**
     20     * Unit test of {@link GridPreference#GridPreference}.
     21     */
     22    @Test
     23    void testGridPreference() {
     24        assertNotNull(new GridPreference.Factory().createPreferenceSetting());
     25    }
     26
     27    /**
     28     * Unit test of {@link GridPreference#addGui}: the settings survive a round trip through the panel.
     29     */
     30    @Test
     31    void testAddGui() {
     32        MapGridPaintable.SPACING_X.put(0.25);
     33        MapGridPaintable.ROTATION.put(-12.5);
     34        try {
     35            PreferencesTestUtils.doTestPreferenceSettingAddGui(new GridPreference.Factory(), null);
     36            assertEquals(0.25, MapGridPaintable.SPACING_X.get(), 1e-12);
     37            assertEquals(-12.5, MapGridPaintable.ROTATION.get(), 1e-12);
     38        } finally {
     39            MapGridPaintable.SPACING_X.remove();
     40            MapGridPaintable.ROTATION.remove();
     41        }
     42    }
     43}