001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.marktr;
006
007import java.awt.Color;
008import java.awt.Dimension;
009import java.awt.Graphics;
010import java.awt.geom.Rectangle2D;
011import java.util.function.DoubleSupplier;
012import java.util.function.Supplier;
013
014import javax.accessibility.Accessible;
015import javax.accessibility.AccessibleContext;
016import javax.accessibility.AccessibleValue;
017import javax.swing.JComponent;
018
019import org.openstreetmap.josm.data.preferences.NamedColorProperty;
020import org.openstreetmap.josm.gui.help.Helpful;
021
022/**
023 * Map scale bar, displaying the distance in meter that correspond to 100 px on screen.
024 * @since 115
025 */
026public class MapScaler extends JComponent implements Helpful, Accessible {
027
028    private final DoubleSupplier getDist100Pixel;
029    private final Supplier<Color> colorSupplier;
030
031    private static final int PADDING_LEFT = 5;
032    private static final int PADDING_RIGHT = 50;
033
034    private static final NamedColorProperty SCALER_COLOR = new NamedColorProperty(marktr("scale"), Color.WHITE);
035
036    /**
037     * Constructs a new {@code MapScaler}.
038     * @param mv map view
039     */
040    public MapScaler(NavigatableComponent mv) {
041        this(() -> mv.getDist100Pixel(true), MapScaler::getColor);
042    }
043
044    /**
045     * Constructs a new {@code MapScaler}.
046     * @param getDist100Pixel supplier for distance in meter that correspond to 100 px on screen
047     * @param colorSupplier supplier for color
048     */
049    public MapScaler(DoubleSupplier getDist100Pixel, Supplier<Color> colorSupplier) {
050        this.getDist100Pixel = getDist100Pixel;
051        this.colorSupplier = colorSupplier;
052        setPreferredLineLength(100);
053        setOpaque(false);
054    }
055
056    /**
057     * Sets the preferred length the distance line should have.
058     * @param pixel The length.
059     */
060    public void setPreferredLineLength(int pixel) {
061        setPreferredSize(new Dimension(pixel + PADDING_LEFT + PADDING_RIGHT, 30));
062    }
063
064    @Override
065    public void paint(Graphics g) {
066        g.setColor(colorSupplier.get());
067        double dist100Pixel = getDist100Pixel.getAsDouble();
068        TickMarks tickMarks = new TickMarks(dist100Pixel, getWidth() - PADDING_LEFT - PADDING_RIGHT);
069        tickMarks.paintTicks(g);
070    }
071
072    /**
073     * Returns the color of map scaler.
074     * @return the color of map scaler
075     */
076    public static Color getColor() {
077        return SCALER_COLOR.get();
078    }
079
080    @Override
081    public String helpTopic() {
082        return ht("/MapView/Scaler");
083    }
084
085    @Override
086    public AccessibleContext getAccessibleContext() {
087        if (accessibleContext == null) {
088            accessibleContext = new AccessibleMapScaler();
089        }
090        return accessibleContext;
091    }
092
093    class AccessibleMapScaler extends AccessibleJComponent implements AccessibleValue {
094
095        @Override
096        public Number getCurrentAccessibleValue() {
097            return getDist100Pixel.getAsDouble();
098        }
099
100        @Override
101        public boolean setCurrentAccessibleValue(Number n) {
102            return false;
103        }
104
105        @Override
106        public Number getMinimumAccessibleValue() {
107            return null;
108        }
109
110        @Override
111        public Number getMaximumAccessibleValue() {
112            return null;
113        }
114    }
115
116    /**
117     * This class finds the best possible tick mark positions.
118     * <p>
119     * It will attempt to use steps of 1m, 2.5m, 10m, 25m, ...
120     */
121    private static final class TickMarks {
122
123        private final double dist100Pixel;
124        /**
125         * Distance in meters between two ticks.
126         */
127        private final double spacingMeter;
128        private final int steps;
129        private final int minorStepsPerMajor;
130
131        /**
132         * Creates a new tick mark helper.
133         * @param dist100Pixel The distance of 100 pixel on the map.
134         * @param width The width of the mark.
135         */
136        TickMarks(double dist100Pixel, int width) {
137            this.dist100Pixel = dist100Pixel;
138            double lineDistance = dist100Pixel * width / 100;
139
140            double log10 = Math.log(lineDistance) / Math.log(10);
141            double spacingLog10 = Math.pow(10, Math.floor(log10));
142            int minorStepsPerMajor;
143            double distanceBetweenMinor;
144            if (log10 - Math.floor(log10) < .75) {
145                // Add 2 ticks for every full unit
146                distanceBetweenMinor = spacingLog10 / 2;
147                minorStepsPerMajor = 2;
148            } else {
149                // Add 10 ticks for every full unit
150                distanceBetweenMinor = spacingLog10;
151                minorStepsPerMajor = 5;
152            }
153            // round down to the last major step.
154            int majorSteps = (int) Math.floor(lineDistance / distanceBetweenMinor / minorStepsPerMajor);
155            if (majorSteps >= 4) {
156                // we have many major steps, do not paint the minor now.
157                this.spacingMeter = distanceBetweenMinor * minorStepsPerMajor;
158                this.minorStepsPerMajor = 1;
159            } else {
160                this.minorStepsPerMajor = minorStepsPerMajor;
161                this.spacingMeter = distanceBetweenMinor;
162            }
163            steps = majorSteps * this.minorStepsPerMajor;
164        }
165
166        /**
167         * Paint the ticks to the graphics.
168         * @param g The graphics to paint on.
169         */
170        public void paintTicks(Graphics g) {
171            double spacingPixel = spacingMeter / (dist100Pixel / 100);
172            double textBlockedUntil = -1;
173            for (int step = 0; step <= steps; step++) {
174                int x = (int) (PADDING_LEFT + spacingPixel * step);
175                boolean isMajor = step % minorStepsPerMajor == 0;
176                int paddingY = isMajor ? 0 : 3;
177                g.drawLine(x, paddingY, x, 10 - paddingY);
178
179                if (step == 0 || step == steps) {
180                    String text;
181                    if (step == 0) {
182                        text = "0";
183                    } else {
184                        text = NavigatableComponent.getDistText(spacingMeter * step);
185                    }
186                    Rectangle2D bound = g.getFontMetrics().getStringBounds(text, g);
187                    int left = (int) (x - bound.getWidth() / 2);
188                    if (textBlockedUntil > left) {
189                        left = (int) (textBlockedUntil + 5);
190                    }
191                    g.drawString(text, left, 23);
192                    textBlockedUntil = left + bound.getWidth() + 2;
193                }
194            }
195            g.drawLine(PADDING_LEFT + 0, 5, (int) (PADDING_LEFT + spacingPixel * steps), 5);
196        }
197    }
198}