/******************************************************************************* * Copyright 2013 Geoscience Australia * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. ******************************************************************************/ package au.gov.ga.earthsci.common.color; import java.awt.Color; import java.util.AbstractMap; import java.util.Collections; import java.util.Map; import java.util.Map.Entry; import java.util.TreeMap; import java.util.UUID; import au.gov.ga.earthsci.common.util.IDescribed; import au.gov.ga.earthsci.common.util.INamed; import au.gov.ga.earthsci.worldwind.common.util.Util; import com.jhlabs.image.Colormap; /** * An immutable colour map used for mapping values to colours according to a * colour table * * @author Michael de Hoog (michael.dehoog@ga.gov.au) * @author James Navin (james.navin@ga.gov.au) * * @see au.gov.ga.earthsci.worldwind.common.util.ColorMap */ public class ColorMap implements INamed, IDescribed, Colormap { public static enum InterpolationMode implements INamed, IDescribed { /** * Return the colour for the nearest matching value in the colour table * to the value provided * <p/> * Useful for discretising continuous data. */ NEAREST_MATCH { @Override protected Color getColor(double value, TreeMap<Double, Color> entries, Color nodata) { Entry<Double, Color> ceiling = entries.ceilingEntry(value); Entry<Double, Color> floor = entries.floorEntry(value); if (ceiling == null && floor == null) { return nodata; } if (ceiling == null && floor != null) { return floor.getValue(); } if (ceiling != null && floor == null) { return ceiling.getValue(); } if (Math.abs(ceiling.getKey() - value) > Math.abs(floor.getKey() - value)) { return floor.getValue(); } return ceiling.getValue(); } @Override public String getName() { return Messages.ColorMap_NearestMatchName; } @Override public String getDescription() { return Messages.ColorMap_NearestMatchDescription; } }, /** * Return the colour for the value in the colour table that exactly * matches the value provided, or NODATA if none is found. * <p/> * Useful for classification colouring of discrete data. */ EXACT_MATCH { @Override protected Color getColor(double value, TreeMap<Double, Color> entries, Color nodata) { Color result = entries.get(value); if (result == null) { return nodata; } return result; } @Override public String getName() { return Messages.ColorMap_ExactMatchName; } @Override public String getDescription() { return Messages.ColorMap_ExactMatchDescription; } }, /** * Interpolate between the two closest colour entries by separately * interpolating the RGB components of each colour. */ INTERPOLATE_RGB { @Override protected Color getColor(double value, TreeMap<Double, Color> entries, Color nodata) { return doInterpolate(value, entries, nodata, false); } @Override public String getName() { return Messages.ColorMap_RGBInterpolateName; } @Override public String getDescription() { return Messages.ColorMap_RGBInterpolateDescription; } }, /** * Interpolate between the two closest colour entries by interpolating * along in HSB space, wrapping around the Hue axis where necessary, and * converting back to RGB. */ INTERPOLATE_HUE { @Override protected Color getColor(double value, TreeMap<Double, Color> entries, Color nodata) { return doInterpolate(value, entries, nodata, true); } @Override public String getName() { return Messages.ColorMap_HueInterpolateName; } @Override public String getDescription() { return Messages.ColorMap_HueInterpolateDescription; } }; protected abstract Color getColor(double value, TreeMap<Double, Color> entries, Color nodata); private static Color doInterpolate(double value, TreeMap<Double, Color> entries, Color nodata, boolean hue) { Entry<Double, Color> floor = entries.floorEntry(value); Entry<Double, Color> ceiling = entries.ceilingEntry(value); if (floor == null && ceiling == null) { return nodata; } double mixer = 0; if (floor != null && ceiling != null) { double window = ceiling.getKey() - floor.getKey(); if (window > 0) { mixer = (value - floor.getKey()) / window; } } Color floorColor = floor == null ? null : floor.getValue(); Color ceilingColor = ceiling == null ? null : ceiling.getValue(); return Util.interpolateColor(floorColor, ceilingColor, mixer, hue); } } private static final Color DEFAULT_NODATA = new Color(0, 0, 0, 0); // Note that package-private is used to restrict subclassing to the MutableColorMap implementation Color nodataColour; boolean valuesArePercentages; InterpolationMode mode; final TreeMap<Double, Color> entries = new TreeMap<Double, Color>(); String name; String description; /** * Create a new colour map using the provided entries. The instance will use * RGB interpolation, will return {@code RGB(0,0,0,0)} for NODATA values and * use absolute values as map entries. * * @param entries * The colour map entries to use */ public ColorMap(Map<Double, Color> entries) { this(null, null, entries, DEFAULT_NODATA, InterpolationMode.INTERPOLATE_RGB, false); } /** * Create a new fully configured colour map. * * * @param name * The (localised) human-readable name for the colour map. * (Optional - if missing will use an auto-generated name). * @param description * The (localised) human-readable description for the colour map * (Optional - if missing will have no description). * @param entries * The colour map entries to use (Required). * @param nodataColour * The nodata colour to associate with this map (Optional - if * missing will return <code>null</code> for nodata). * @param mode * The interpolation mode for this map (Optional - if missing * will default to {@link InterpolationMode#INTERPOLATE_RGB}) * @param valuesArePercentages * Whether the map uses percentages (<code>true</code>) or * absolute values (<code>true</code>). */ public ColorMap(String name, String description, Map<Double, Color> entries, Color nodataColour, InterpolationMode mode, boolean valuesArePercentages) { if (entries != null) { this.entries.putAll(entries); } this.nodataColour = nodataColour; this.mode = mode == null ? InterpolationMode.INTERPOLATE_RGB : mode; this.valuesArePercentages = valuesArePercentages; this.name = name == null ? createDefaultName() : name; this.description = description; } final static String createDefaultName() { return Messages.ColorMap_DefaultColorMapName + UUID.randomUUID().toString(); } /** * Return the colour for the given value, using the appropriate * interpolation mode. * <p/> * If {@link #isPercentageBased()}, expects a percentage value in the range * {@code [0,1]} as input. Otherwise expects an absolute value. * * @param value * The value to lookup in the map * * @return The appropriate colour for the given value */ public Color getColor(double value) { return mode.getColor(value, entries, nodataColour); } @Override public int getColor(float v) { return getColor((double) v).getRGB(); } /** * Return the colour for the given absolute value, using the appropriate * interpolation mode. * <p/> * If {@link #isPercentageBased()}, will calculate a percentage to use based * on the {@code min} and {@code max} values. Otherwise will use the * absolute value directly. * * @param absoluteValue * The absolute data value to look up * @param min * The minimum absolute value in the source data * @param max * The maximum absolute value in the source data * * @return The appropriate colour to use */ public Color getColor(double absoluteValue, double min, double max) { if (valuesArePercentages) { double percentage; if (min == max) { percentage = 0; } else { percentage = (absoluteValue - Math.min(min, max)) / (Math.max(min, max) - Math.min(min, max)); } return getColor(percentage); } return getColor(absoluteValue); } /** * @return the NODATA colour for this colour map */ public Color getNodataColour() { return nodataColour; } /** * Return whether this colour map uses percentages in the range * {@code [0,1]} rather than absolute values as keys in the map. * * @return <code>true</code> if values are interpreted as percentages; * <code>false</code> otherwise. */ public boolean isPercentageBased() { return valuesArePercentages; } /** * Return the interpolation mode being used by this colour map * * @return the mode */ public InterpolationMode getMode() { return mode; } /** * Return the entries in this colour map. * * @return a read-only view of the entries in this colour map */ public Map<Double, Color> getEntries() { return Collections.unmodifiableMap(entries); } /** * Return the number of entries in this colour map * * @return The number of entries in this colour map */ public int getSize() { return entries.size(); } /** * Return whether this map is empty * * @return <code>true</code> if this map contains no entries; * <code>false</code> otherwise. */ public boolean isEmpty() { return entries.isEmpty(); } /** * Return the first entry in this colour map, if one exists * * @return The first entry in this colour map */ public Entry<Double, Color> getFirstEntry() { return entries.firstEntry(); } /** * Return the last entry in this colour map, if one exists * * @return The last entry in this colour map */ public Entry<Double, Color> getLastEntry() { return entries.lastEntry(); } /** * Return the entry in the colour map for the next lowest value to the one * given. * * @param value * The value to retrieve the previous entry from * * @return The previous entry, or <code>null</code> if none exists in the * map. */ public Entry<Double, Color> getPreviousEntry(Double value) { if (value == null) { return null; } return entries.lowerEntry(value); } /** * Return the entry in the colour map for the next highest value to the one * given. * * @param value * The value to retrieve the next entry from * * @return The next entry, or <code>null</code> if none exists in the map. */ public Entry<Double, Color> getNextEntry(Double value) { if (value == null) { return null; } return entries.higherEntry(value); } /** * Return the nearest entry in the map to the provided value, or * <code>null</code> if there are no entries in the map. * * @param value * The value to find the nearest entry for * * @return The nearest entry to the provided value, or <code>null</code> if * none exists */ public Entry<Double, Color> getNearestEntry(Double value) { if (value == null) { return null; } Entry<Double, Color> ceiling = entries.ceilingEntry(value); Entry<Double, Color> floor = entries.floorEntry(value); if (ceiling == null && floor == null) { return null; } if (ceiling == null && floor != null) { return floor; } if (ceiling != null && floor == null) { return ceiling; } if (Math.abs(ceiling.getKey() - value) > Math.abs(floor.getKey() - value)) { return floor; } return ceiling; } /** * Return the entry with the given value, if one exists. * * @param value * The value to retrieve the entry for * * @return The entry for the given value, or <code>null</code> if one cannot * be found */ public Entry<Double, Color> getEntry(Double value) { if (value == null) { return null; } if (!entries.containsKey(value)) { return null; } return new AbstractMap.SimpleEntry<Double, Color>(value, entries.get(value)); } @Override public String getName() { return name; } @Override public String getDescription() { return description; } @Override public boolean equals(Object obj) { if (obj == this) { return true; } if (!(obj instanceof ColorMap)) { return false; } ColorMap other = (ColorMap) obj; // Equality based on all fields (use short-circuiting to avoid unnecessary tests) boolean equals = au.gov.ga.earthsci.common.util.Util.nullSafeEquals(name, other.name); equals = equals && au.gov.ga.earthsci.common.util.Util.nullSafeEquals(description, other.description); equals = equals && (mode == other.mode); equals = equals && (valuesArePercentages == other.valuesArePercentages); equals = equals && au.gov.ga.earthsci.common.util.Util.nullSafeEquals(nodataColour, other.nodataColour); equals = equals && (entries.size() == other.entries.size()); for (Entry<Double, Color> thisEntry : entries.entrySet()) { equals = equals && other.entries.containsKey(thisEntry.getKey()); equals = equals && thisEntry.getValue().equals(other.entries.get(thisEntry.getKey())); } return equals; } @Override public int hashCode() { int hash = 31; if (name != null) { hash += name.hashCode(); } if (description != null) { hash += description.hashCode(); } if (nodataColour != null) { hash += nodataColour.hashCode(); } if (mode != null) { hash += mode.hashCode(); } if (valuesArePercentages) { hash += 1; } hash += entries.hashCode(); return hash; } public au.gov.ga.earthsci.worldwind.common.util.ColorMap toLegacy() { au.gov.ga.earthsci.worldwind.common.util.ColorMap legacy = new au.gov.ga.earthsci.worldwind.common.util.ColorMap(); for (Entry<Double, Color> entry : entries.entrySet()) { legacy.put(entry.getKey(), entry.getValue()); } legacy.setValuesPercentages(isPercentageBased()); legacy.setInterpolateHue(mode == InterpolationMode.INTERPOLATE_HUE); return legacy; } }