From 2484e723b1c5e6e2cf5911fb66835e6c575b1670 Mon Sep 17 00:00:00 2001 From: "taylor.smock" Date: Tue, 8 Oct 2024 17:56:20 +0000 Subject: [PATCH] Fix #23926: Extend GPS legend for time information, improve design (patch by Pauline, modified) Modifications are as follows: * Reduction of code duplication * Addition of functions in ColorHelper to calculate contrast ratios git-svn-id: https://josm.openstreetmap.de/svn/trunk@19236 0c6e7542-c601-0410-84e7-c038aed88b3b --- .../josm/gui/layer/gpx/GpxDrawHelper.java | 35 +- .../openstreetmap/josm/tools/ColorHelper.java | 50 ++- .../openstreetmap/josm/tools/ColorScale.java | 309 ++++++++++++++++-- 3 files changed, 358 insertions(+), 36 deletions(-) diff --git a/src/org/openstreetmap/josm/gui/layer/gpx/GpxDrawHelper.java b/src/org/openstreetmap/josm/gui/layer/gpx/GpxDrawHelper.java index a37c18df4f6..fffaa469055 100644 --- a/src/org/openstreetmap/josm/gui/layer/gpx/GpxDrawHelper.java +++ b/src/org/openstreetmap/josm/gui/layer/gpx/GpxDrawHelper.java @@ -236,16 +236,22 @@ public class GpxDrawHelper implements SoMChangeListener, MapViewPaintable.LayerP // The heat map was invalidated since the last draw. private boolean gpxLayerInvalidated; + /** minTime saves the start time of the track as epoch seconds */ + private double minTime; + /** maxTime saves the end time of the track as epoch seconds */ + private double maxTime; + + private void setupColors() { hdopAlpha = Config.getPref().getInt("hdop.color.alpha", -1); velocityScale = ColorScale.createHSBScale(256); /* Colors (without custom alpha channel, if given) for HDOP painting. */ hdopScale = ColorScale.createHSBScale(256).makeReversed().addTitle(tr("HDOP")); qualityScale = ColorScale.createFixedScale(rtkLibQualityColors).addTitle(tr("Quality")).addColorBarTitles(rtkLibQualityNames); - fixScale = ColorScale.createFixedScale(gpsFixQualityColors).addTitle(tr("GPS fix")).addColorBarTitles(gpsFixQualityNames); - refScale = ColorScale.createCyclicScale(1).addTitle(tr("GPS ref")); - dateScale = ColorScale.createHSBScale(256).addTitle(tr("Time")); - directionScale = ColorScale.createCyclicScale(256).setIntervalCount(4).addTitle(tr("Direction")); + fixScale = ColorScale.createFixedScale(gpsFixQualityColors).addTitle(tr("GPS fix value")).addColorBarTitles(gpsFixQualityNames); + refScale = ColorScale.createCyclicScale(1).addTitle(tr("GPS Ref-ID")); + dateScale = ColorScale.createHSBScale(256).addTitle(tr("Track date")); + directionScale = ColorScale.createCyclicScale(256).setIntervalCount(4).addTitle(tr("Direction [°]")); systemOfMeasurementChanged(null, null); } @@ -253,7 +259,7 @@ private void setupColors() { @Override public void systemOfMeasurementChanged(String oldSoM, String newSoM) { SystemOfMeasurement som = SystemOfMeasurement.getSystemOfMeasurement(); - velocityScale.addTitle(tr("Velocity, {0}", som.speedName)); + velocityScale.addTitle(tr("Velocity [{0}]", som.speedName)); layer.invalidate(); } @@ -622,6 +628,9 @@ public void calculateColors() { Interval interval = data.getMinMaxTimeForAllTracks().orElse(new Interval(Instant.EPOCH, Instant.now())); minval = interval.getStart().getEpochSecond(); maxval = interval.getEnd().getEpochSecond(); + this.minTime = minval; + this.maxTime = maxval; + dateScale.setRange(minval, maxval); } @@ -641,7 +650,7 @@ public void calculateColors() { if (!refs.isEmpty()) { Collections.sort(refs); String[] a = {}; - refScale = ColorScale.createCyclicScale(refs.size()).addTitle(tr("GPS ref")).addColorBarTitles(refs.toArray(a)); + refScale = ColorScale.createCyclicScale(refs.size()).addTitle(tr("GPS ref ID")).addColorBarTitles(refs.toArray(a)); refScale.setRange(0, refs.size()); } } @@ -1618,18 +1627,20 @@ public void drawColorBar(Graphics2D g, MapView mv) { g.setComposite(AlphaComposite.SrcOver.derive(1.00f)); if (colored == ColorMode.HDOP) { - hdopScale.drawColorBar(g, w-30, 50, 20, 100, 1.0); + hdopScale.drawColorBar(g, w-10, 50, 20, 100, 1.0); } else if (colored == ColorMode.QUALITY) { - qualityScale.drawColorBar(g, w-30, 50, 20, 100, 1.0); + qualityScale.drawColorBar(g, w-10, 50, 20, 100, 1.0); } else if (colored == ColorMode.FIX) { - fixScale.drawColorBar(g, w-30, 50, 20, 175, 1.0); + fixScale.drawColorBar(g, w-10, 50, 20, 175, 1.0); } else if (colored == ColorMode.REF) { - refScale.drawColorBar(g, w-30, 50, 20, 175, 1.0); + refScale.drawColorBar(g, w-10, 50, 20, 175, 1.0); } else if (colored == ColorMode.VELOCITY) { SystemOfMeasurement som = SystemOfMeasurement.getSystemOfMeasurement(); - velocityScale.drawColorBar(g, w-30, 50, 20, 100, som.speedValue); + velocityScale.drawColorBar(g, w-10, 50, 20, 100, som.speedValue); } else if (colored == ColorMode.DIRECTION) { - directionScale.drawColorBar(g, w-30, 50, 20, 100, 180.0/Math.PI); + directionScale.drawColorBar(g, w-10, 50, 20, 100, 180.0/Math.PI); + } else if (colored == ColorMode.TIME) { + dateScale.drawColorBarTime(g, w-10, 50, 20, 100, this.minTime, this.maxTime); } } diff --git a/src/org/openstreetmap/josm/tools/ColorHelper.java b/src/org/openstreetmap/josm/tools/ColorHelper.java index 3e4fd91fe31..cd80be42d2c 100644 --- a/src/org/openstreetmap/josm/tools/ColorHelper.java +++ b/src/org/openstreetmap/josm/tools/ColorHelper.java @@ -73,9 +73,13 @@ public static String color2html(Color color, boolean withAlpha) { */ public static Color getForegroundColor(Color bg) { // http://stackoverflow.com/a/3943023/2257172 - return bg == null ? null : - (bg.getRed()*0.299 + bg.getGreen()*0.587 + bg.getBlue()*0.114) > 186 ? - Color.BLACK : Color.WHITE; + if (bg == null) { + return null; + } + if (calculateContrastRatio(Color.WHITE, bg) > calculateContrastRatio(Color.BLACK, bg)) { + return Color.WHITE; + } + return Color.BLACK; } /** @@ -128,4 +132,44 @@ public static Color alphaMultiply(Color color, float alphaFactor) { public static Color complement(Color clr) { return new Color(255 - clr.getRed(), 255 - clr.getGreen(), 255 - clr.getBlue(), clr.getAlpha()); } + + /** + * Calculate the relative "luminance" of a color. This is mostly useful for choosing background/foreground colours + * @see + * constrast ratio + */ + private static double calculateLuminance(Color color) { + final double rs = color.getRed() / 255.0; + final double gs = color.getGreen() / 255.0; + final double bs = color.getBlue() / 255.0; + final double r = calculateLuminanceStepFunction(rs); + final double g = calculateLuminanceStepFunction(gs); + final double b = calculateLuminanceStepFunction(bs); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + /** + * This is a step function for {@link #calculateLuminance(Color)} + * @param color The color to get the values for + * @return The value to use when calculating relative luminance + */ + private static double calculateLuminanceStepFunction(double color) { + if (color <= 0.03928) { + return color / 12.92; + } + return Math.pow((color + 0.055) / 1.055, 2.4); + } + + /** + * Calculate the contrast between two colors (e.g. {@link Color#black} and {@link Color#white}). + * @param first The first color to use + * @param second The second color to use + * @return The contrast ratio ((L1 + 0.05)/(L2 + 0.05)) + * @since 19236 + */ + public static double calculateContrastRatio(Color first, Color second) { + final double fL = calculateLuminance(first); + final double sL = calculateLuminance(second); + return (Math.max(fL, sL) + 0.05) / (Math.min(fL, sL) + 0.05); + } } diff --git a/src/org/openstreetmap/josm/tools/ColorScale.java b/src/org/openstreetmap/josm/tools/ColorScale.java index 7b4dd4ad048..3b421fe418d 100644 --- a/src/org/openstreetmap/josm/tools/ColorScale.java +++ b/src/org/openstreetmap/josm/tools/ColorScale.java @@ -1,16 +1,40 @@ // License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.tools; +import static org.openstreetmap.josm.tools.I18n.marktr; + import java.awt.Color; import java.awt.FontMetrics; import java.awt.Graphics2D; +import java.awt.Font; import java.util.Arrays; +import java.util.Date; +import java.time.ZoneId; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +import org.openstreetmap.josm.data.preferences.NamedColorProperty; + +import javax.swing.UIManager; /** * Utility class that helps to work with color scale for coloring GPX tracks etc. * @since 7319 */ public final class ColorScale { + private static final Color LEGEND_BACKGROUND = new NamedColorProperty(marktr("gpx legend background"), new Color(180, 180, 180, 160)).get(); + private static final Color LEGEND_TEXT_OUTLINE_DARK = new NamedColorProperty(marktr("gpx legend text outline dark"), + new Color(102, 102, 102)).get(); + private static final Color LEGEND_TEXT_OUTLINE_BRIGHT = new NamedColorProperty(marktr("gpx legend text outline bright"), + new Color(204, 204, 204)).get(); + private static final Color LEGEND_TITLE = new NamedColorProperty(marktr("gpx legend title color"), new Color(0, 0, 0)).get(); + + private static final String DAY_TIME_FORMAT = "yyyy-MM-dd HH:mm"; + private static final String TIME_FORMAT = "HH:mm:ss"; + /** Padding for the legend (from the text to the edge of the rectangle) */ + private static final byte PADDING = 19; + private double min, max; private Color noDataColor; private Color belowMinColor; @@ -224,6 +248,29 @@ public ColorScale makeReversed() { return this; } + /** + * draws an outline for the legend texts + * @param g The graphics to draw on + * @param txt The text to draw the outline + * @param x Text x + * @param y Text y + * @param color The color of the text + */ + private void drawOutline(final Graphics2D g, final String txt, final int x, final int y, final Color color) { + if (ColorHelper.calculateContrastRatio(color, LEGEND_TEXT_OUTLINE_DARK) >= + ColorHelper.calculateContrastRatio(color, LEGEND_TEXT_OUTLINE_BRIGHT)) { + g.setColor(LEGEND_TEXT_OUTLINE_DARK); + } else { + g.setColor(LEGEND_TEXT_OUTLINE_BRIGHT); + } + + g.drawString(txt, x -1, y -1); + g.drawString(txt, x +1, y -1); + g.drawString(txt, x -1, y +1); + g.drawString(txt, x +1, y +1); + g.setColor(color); + } + /** * Draws a color bar representing this scale on the given graphics * @param g The graphics to draw on @@ -233,47 +280,267 @@ public ColorScale makeReversed() { * @param h Rect height * @param valueScale The scale factor of the values */ - public void drawColorBar(Graphics2D g, int x, int y, int w, int h, double valueScale) { - int n = colors.length; + public void drawColorBar(final Graphics2D g, final int x, final int y, final int w, final int h, final double valueScale) { + final int n = colors.length; + + final FontMetrics fm = calculateFontMetrics(g); + + g.setColor(LEGEND_BACKGROUND); + + // color bar texts width & height + final int fw; + final int fh = fm.getHeight() / 2; + + // calculates the width of the color bar texts + if (colorBarTitles != null && colorBarTitles.length > 0) { + fw = Arrays.stream(colorBarTitles).mapToInt(fm::stringWidth).max().orElse(50); + } else { + fw = fm.stringWidth( + String.valueOf(Math.max((int) Math.abs(max * valueScale), (int) Math.abs(min * valueScale)))) + + fm.stringWidth("0.123"); + } + + // background rectangle + final int[] t = drawBackgroundRectangle(g, x, y, w, h, fw, fh, fm.stringWidth(title)); + final int xRect = t[0]; + final int rectWidth = t[1]; + final int xText = t[2]; + final int titleWidth = t[3]; + + // colorbar for (int i = 0; i < n; i++) { g.setColor(colors[i]); if (w < h) { - g.fillRect(x, y+i*h/n, w, h/n+1); + double factor = n == 6 ? 1.2 : 1.07 + (0.045 * Math.log(n)); + if (n < 200) { + g.fillRect(xText + fw + PADDING / 3, y - PADDING / 2 + i * (int) ((double) h / n * factor), + w, (int) ((double) h / n * factor)); + } else { + g.fillRect(xText + fw + PADDING / 3, y - PADDING / 2 + i * h / (int) (n * 0.875), w, (h / n + 1)); + } } else { - g.fillRect(x+i*w/n, y, w/n+1, h); + g.fillRect(xText + fw + 7 + i * w / n, y, w / n, h + 1); } } - int fw, fh; - FontMetrics fm = g.getFontMetrics(); - fh = fm.getHeight()/2; - if (colorBarTitles != null && colorBarTitles.length > 0) { - fw = Arrays.stream(colorBarTitles).mapToInt(fm::stringWidth).max().orElse(50); - } else { - fw = fm.stringWidth( - String.valueOf(Math.max((int) Math.abs(max * valueScale), (int) Math.abs(min * valueScale)))) - + fm.stringWidth("0.123"); + // legend title + if (title != null) { + g.setColor(LEGEND_TITLE); + g.drawString(title, xRect + rectWidth / 2 - titleWidth / 2, y - fh * 3 / 2 - 10); } + + // legend texts + drawLegend(g, y, w, h, valueScale, fh, fw, xText); + g.setColor(noDataColor); + } + + /** + * Draws a color bar representing the time scale on the given graphics + * @param g The graphics to draw on + * @param x Rect x + * @param y Rect y + * @param w Color bar width + * @param h Color bar height + * @param minVal start time of the track + * @param maxVal end time of the track + */ + public void drawColorBarTime(final Graphics2D g, final int x, final int y, final int w, final int h, + final double minVal, final double maxVal) { + final int n = colors.length; + + final FontMetrics fm = calculateFontMetrics(g); + + g.setColor(LEGEND_BACKGROUND); + + final int padding = PADDING; + + // color bar texts width & height + final int fw; + final int fh = fm.getHeight() / 2; + + // calculates the width of the colorbar texts + if (maxVal - minVal > 86400) { + fw = fm.stringWidth(DAY_TIME_FORMAT); + } else { + fw = fm.stringWidth(TIME_FORMAT); + } + + // background rectangle + final int[] t = drawBackgroundRectangle(g, x, y, w, h, fw, fh, fm.stringWidth(title)); + final int xRect = t[0]; + final int rectWidth = t[1]; + final int xText = t[2]; + final int titleWidth = t[3]; + + // colorbar + for (int i = 0; i < n; i++) { + g.setColor(colors[i]); + if (w < h) { + g.fillRect(xText + fw + padding / 3, y - padding / 2 + i * h / (int) (n * 0.875), w, (h / n + 1)); + } else { + g.fillRect(xText + fw + padding / 3 + i * w / n, y, w / n + 1, h); + } + } + + // legend title if (title != null) { - g.drawString(title, x-fw-3, y-fh*3/2); + g.setColor(LEGEND_TITLE); + g.drawString(title, xRect + rectWidth / 2 - titleWidth / 2, y - fh * 3 / 2 - padding / 2); } + + // legend texts + drawTimeLegend(g, y, x, h, minVal, maxVal, fh, fw, xText); + + g.setColor(noDataColor); + } + + private static FontMetrics calculateFontMetrics(final Graphics2D g) { + final Font newFont = UIManager.getFont("PopupMenu.font"); + g.setFont(newFont); + return g.getFontMetrics(); + } + + /** + * Draw the background rectangle + * @param g The graphics to draw on + * @param x Rect x + * @param y Rect y + * @param w Color bar width + * @param h Color bar height + * @param fw The font width + * @param fh The font height + * @param titleWidth The width of the title + * @return an @{code int[]} of [xRect, rectWidth, xText, titleWidth] TODO investigate using records in Java 17 + */ + private int[] drawBackgroundRectangle(final Graphics2D g, final int x, final int y, + final int w, final int h, final int fw, final int fh, + int titleWidth) { + final int xRect; + final int rectWidth; + final int xText; + final int arcWidth = 20; + final int arcHeight = 20; + if (fw + w > titleWidth) { + rectWidth = w + fw + PADDING * 2; + xRect = x - rectWidth; + xText = xRect + (int) (PADDING / 1.2); + g.fillRoundRect(xRect, (fh * 3 / 2), rectWidth, h + y - (fh * 3 / 2) + (int) (PADDING / 1.5), arcWidth, arcHeight); + } else { + if (titleWidth >= 120) { + titleWidth = 120; + } + rectWidth = w + titleWidth + PADDING + PADDING / 2; + xRect = x - rectWidth; + xText = xRect + PADDING / 2 + rectWidth / 2 - fw; + g.fillRoundRect(xRect, (fh * 3 / 2), rectWidth, h + y - (fh * 3 / 2) + (int) (PADDING / 1.5), arcWidth, arcHeight); + } + return new int[] {xRect, rectWidth, xText, titleWidth}; + } + + /** + * Draws the legend for the color bar representing the time scale on the given graphics + * @param g The graphics to draw on + * @param y Rect y + * @param w Color bar width + * @param h Color bar height + * @param fw The font width + * @param fh The font height + * @param valueScale The scale factor of the values + * @param xText The location to start drawing the text (x-axis) + */ + private void drawLegend(final Graphics2D g, final int y, final int w, final int h, final double valueScale, + final int fh, final int fw, final int xText) { for (int i = 0; i <= intervalCount; i++) { - g.setColor(colors[(int) (1.0*i*n/intervalCount-1e-10)]); - String txt; + final String txt; + final Color color = colors[(int) (1.0 * i * colors.length / intervalCount - 1e-10)]; + g.setColor(color); + if (colorBarTitles != null && i < colorBarTitles.length) { txt = colorBarTitles[i]; } else { final double val = min+i*(max-min)/intervalCount; txt = String.format("%.3f", val*valueScale); } - if (intervalCount == 0) { - g.drawString(txt, x-fw-3, y+h/2+fh/2); - } else if (w < h) { - g.drawString(txt, x-fw-3, y+i*h/intervalCount+fh/2); + drawLegendText(g, y, w, h, fh, fw, xText, i, color, txt); + } + } + + /** + * Draws the legend for the color bar representing the time scale on the given graphics + * @param g The graphics to draw on + * @param y Rect y + * @param w Color bar width + * @param h Color bar height + * @param minVal start time of the track + * @param maxVal end time of the track + * @param fw The font width + * @param fh The font height + * @param xText The location to start drawing the text (x-axis) + */ + private void drawTimeLegend(final Graphics2D g, final int y, final int w, final int h, + final double minVal, final double maxVal, + final int fh, final int fw, final int xText) { + for (int i = 0; i <= intervalCount; i++) { + final String txt; + final Color color = colors[(int) (1.0 * i * colors.length / intervalCount - 1e-10)]; + g.setColor(color); + + if (colorBarTitles != null && i < colorBarTitles.length) { + txt = colorBarTitles[i]; } else { - g.drawString(txt, x+i*w/intervalCount-fw/2, y+fh-3); + final double val = minVal + i * (maxVal - minVal) / intervalCount; + final long longval = (long) val; + + final Date date = new Date(longval * 1000L); + final Instant dateInst = date.toInstant(); + + final ZoneId gmt = ZoneId.of("GMT"); + final ZonedDateTime zonedDateTime = dateInst.atZone(gmt); + + String formatted; + + if (maxVal-minVal > 86400) { + final DateTimeFormatter day = DateTimeFormatter.ofPattern(DAY_TIME_FORMAT); + formatted = zonedDateTime.format(day); + } else { + final DateTimeFormatter time = DateTimeFormatter.ofPattern(TIME_FORMAT); + formatted = zonedDateTime.format(time); + } + + txt = formatted; } + drawLegendText(g, y, w, h, fh, fw, xText, i, color, txt); + } + } + + /** + * Draws the legend for the color bar representing the time scale on the given graphics + * @param g The graphics to draw on + * @param y Rect y + * @param w Color bar width + * @param h Color bar height + * @param fw The font width + * @param fh The font height + * @param xText The location to start drawing the text (x-axis) + * @param color The color of the text to draw + * @param txt The text string to draw + * @param i The index of the legend (so we can calculate the y location) + */ + private void drawLegendText(Graphics2D g, int y, int w, int h, int fh, int fw, int xText, + int i, Color color, String txt) { + + if (intervalCount == 0) { + drawOutline(g, txt, xText, y + h / 2 + fh / 2, color); + g.drawString(txt, xText, y + h / 2 + fh / 2); + } else if (w < h) { + drawOutline(g, txt, xText, y + i * h / intervalCount + fh / 2, color); + g.drawString(txt, xText, y + i * h / intervalCount + fh / 2); + } else { + final int xLoc = xText + i * w / intervalCount - fw / 2 - (int) (PADDING / 1.3); + final int yLoc = y + fh - 5; + drawOutline(g, txt, xLoc, yLoc, color); + g.drawString(txt, xLoc, yLoc); } } }