/* * GeoTools - The Open Source Java GIS Toolkit * http://geotools.org * * (C) 2017, Open Source Geospatial Foundation (OSGeo) * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; * version 2.1 of the License. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. */ package org.geotools.mbstyle.parse; import java.awt.Color; import java.util.ArrayList; import java.util.Arrays; import java.util.EnumSet; import java.util.Enumeration; import java.util.List; import java.util.stream.Collectors; import org.geotools.coverage.processing.operation.Interpolate; import org.geotools.filter.function.CategorizeFunction; import org.geotools.filter.function.RecodeFunction; import org.geotools.filter.function.math.FilterFunction_pow; import org.json.simple.JSONArray; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import org.opengis.filter.FilterFactory2; import org.opengis.filter.expression.Expression; import org.opengis.filter.expression.Function; import org.opengis.filter.expression.Literal; /** * MBFunction json wrapper, allowing conversion to a GeoTools Expression. * <p> * Each function is evaluated according type: {@link FunctionType#IDENTITY}, * {@link FunctionType#INTERVAL}, {@link FunctionType#CATEGORICAL}, * {@link FunctionType#EXPONENTIAL}. * <p> * We have several methods that intelligently review {@link #getType()} and produce the correct * expression:</p> * <ul> * <li>{@link #color()}</li> * <li>{@link #numeric()}</li> * <li>{@link #function(Class)}</li> * <li>{@link #enumeration(Class)}</li> * </ul> * */ public class MBFunction { final protected MBObjectParser parse; final protected JSONObject json; private FilterFactory2 ff; JSONParser parser = new JSONParser(); public MBFunction(JSONObject json) { this(new MBObjectParser(MBFunction.class), json); } public MBFunction(MBObjectParser parse, JSONObject json) { this.parse = parse; this.ff = parse.getFilterFactory(); this.json = json; } /** Optional type, one of identity, exponential, interval, categorical. */ public enum FunctionType { /** Functions return their input as their output. */ IDENTITY, /** * Functions generate an output by interpolating between stops just less than and just * greater than the function input. The domain must be numeric. This is the default for * properties marked with "exponential" symbol. * <p> * This is the default function type for continuous value (such as color or line width).</p> * <p> * Maps to {@link Interpolate}, requiring use of {@link FilterFunction_pow} for base other that 1.</p> */ EXPONENTIAL, /** * Functions return the output value of the stop just less than the function input. The * domain must be numeric. This is the default for properties marked with thie "interval" * symbol. * <p> * This is the default function type for enums values (such as line_cap).</p> * <p> * Maps to {@link CategorizeFunction}.</p> */ INTERVAL, /** * Functions return the output value of the stop equal to the function input. * <p> * Maps to {@link RecodeFunction}.</p> */ CATEGORICAL } /** * Access the function 'type', or null if not specified. * <p> * Depending on the domain you are working with ( {@link #enumeration(Class)}, {@link #color()}, * {@link #enumeration(Class)}} ) the default value to use is different. These functions check * for null and use the appropriate setting. * </p> * * @return function type, or null if not defined. */ public FunctionType getType() { String type = parse.get(json, "type", null); if (type == null) { return null; } switch (type) { case "identity": return FunctionType.IDENTITY; case "exponential": return FunctionType.EXPONENTIAL; case "interval": return FunctionType.INTERVAL; case "categorical": return FunctionType.CATEGORICAL; default: throw new MBFormatException("Function type \"" + type + "\" invalid - expected identity, exponential, interval, categorical"); } } /** * <p> * A value to serve as a fallback function result when a value isn't otherwise available. It is used in the * following circumstances: * </p> * * <ul> * <li>In categorical functions, when the feature value does not match any of the stop domain values.</li> * <li>In property and zoom-and-property functions, when a feature does not contain a value for the specified * property.</li> * <li>In identity functions, when the feature value is not valid for the style property (for example, if the * function is being used for a circle-color property but the feature property value is not a string or not a valid * color).</li> * <li>In interval or exponential property and zoom-and-property functions, when the feature value is not * numeric.</li> * </ul> * * <p> * If no default is provided, the style property's default is used in these circumstances. * </p> * * @return The function's default value, or null if none was provided. */ public Object getDefault() { if (json == null || !json.containsKey("default")) { return null; } else { return json.get("default"); } } /** * <p> * Return the function type, falling back to the default function type for the provided {@link Class} if no function * type is explicitly declared. * The parameter is necessary because different output classes will have different default function types. * </p> * * <p> * Examples (For a function with no explicitly declared type): * </p> * * <pre> * getTypeWithDefault(String.class); // -> "interval" function type * getTypeWithDefault(Number.class); // -> "exponential" function type * </pre> * * @see <a href="https://www.mapbox.com/mapbox-gl-js/style-spec/#types-function">The "type" header under Mapbox * Spec: Functions</a> * @param clazz The class for which to return the default function type * @return The function type, falling back to the default when the provided {@link Class} is the return type. */ public FunctionType getTypeWithDefault(Class<?> clazz) { // If a function type is explicitly declared, return that. FunctionType declaredType = getType(); if (declaredType != null) { return declaredType; } // Otherwise, return the correct default type for the provided class. if (Color.class.isAssignableFrom(clazz) || Number.class.isAssignableFrom(clazz)) { return FunctionType.EXPONENTIAL; } else { return FunctionType.INTERVAL; } } /** * If specified, the function will take the specified feature property as an input. See Zoom * Functions and Property Functions for more information. * * @return property evaluated by function, optional (may be null for zoom functions). */ public String getProperty() { return parse.optional(String.class, json, "property", null); } /** * Function category property, zoom, or property and zoom. Defined as: * * <ul> * <li>property: Each stop is an array with two elements, the first is a property input value * and the second is a function output value. Note that support for property functions is not * available across all properties and platforms at this time. * * <pre> * { "circle-color": { * "property": "temperature", * "stops": [ * [0, 'blue'], * [100, 'red'] * ] * } * } * </pre> * * </li> * <li>zoom: Each stop is an array with two elements: the first is a zoom level and the second * is a function output value. * * <pre> * { "circle-radius": { * "stops": [ * [5, 1], * [10, 2] * ] * } * } * </pre> * * </li> * <li>zoom and property: Each stop is an array with two elements, the first is a property input * value and the second is a function output value. Note that support for property functions is * not available across all properties and platforms at this time. * * <pre> * { "circle-radius": { * "property": "rating", * "stops": [ * [{zoom: 0, value: 0}, 0], * [{zoom: 0, value: 5}, 5], * [{zoom: 20, value: 0}, 0], * [{zoom: 20, value: 5}, 20] * ] * } * } * </pre> * * </li> * </ul> * </ui> */ public static enum FunctionCategory { /** * Property functions allow the appearance of a map feature to change with its properties. * Property functions can be used to visually differentate types of features within the same * layer or create data visualizations. */ PROPERTY, /** * Zoom functions allow the appearance of a map feature to change with map’s zoom level. * Zoom functions can be used to create the illusion of depth and control data density. */ ZOOM; } /** * Programmatically look at the structure of the function and determine if it is a Zoom * function, Property function or Zoom-and-property functions. * * @return Classify function as {@link FunctionCategory#PROPERTY}, {@link FunctionCategory#ZOOM} * or both. */ public EnumSet<FunctionCategory> category() { String property = getProperty(); JSONArray stops = getStops(); if( property != null && stops == null ){ return EnumSet.of(FunctionCategory.PROPERTY); } JSONArray first = parse.jsonArray(stops.get(0)); if (property == null) { return EnumSet.of(FunctionCategory.ZOOM); // no property defined, zoom function } else if (property != null && first.get(0) instanceof JSONObject) { return EnumSet.of(FunctionCategory.ZOOM, FunctionCategory.PROPERTY); } else { return EnumSet.of(FunctionCategory.PROPERTY); } } /** * Functions are defined in terms of input and output values. A set of one input value and one * output value is known as a "stop." * * @return stops definition, optional may be null. */ public JSONArray getStops() { return parse.getJSONArray(json, "stops", null); } /** * (Optional) Number. Default is 1. The exponential base of the interpolation curve. It controls the rate at which * the function output increases. * Higher values make the output increase more towards the high end of the range. With values close to 1 the output * increases linearly. * * @return The exponential base of the interpolation curve. */ public Number getBase() { return parse.optional(Number.class, json, "base", 1); } /** * Extracts input value expression this function is performed against. * <p> * The value is determined by: * <ul> * <li>{@link FunctionCategory#ZOOM}: uses zoomLevel function with wms_scale_denominator evn variable</li> * <li>{@link FunctionCategory#PROPERTY}: uses the provided property to extract value from each feature</li> * </ul> * Zoom and Property functions are not supported and are expected to be reduced by the current zoom level prior to * use. * </p> * @return expression function is evaluated against */ private Expression input() { EnumSet<FunctionCategory> category = category(); if (category.containsAll(EnumSet.of(FunctionCategory.ZOOM, FunctionCategory.PROPERTY))) { // double check if this can/should be supported now that we have a zoomLevel function throw new IllegalStateException("Reduce zoom and property function prior to use."); } else if( category.contains(FunctionCategory.ZOOM)){ return ff.function("zoomLevel", ff.function("env", ff.literal("wms_scale_denominator")), ff.literal("EPSG:3857") ); } else { return ff.property(getProperty()); } } /** * Function as defined by json. * <p> * The value for any layout or paint property may be specified as a function. Functions allow * you to make the appearance of a map feature change with the current zoom level and/or the * feature's properties. * </p> * <p> * Function types: * </p> * <p> * <em> * * @param json Definition of Function * @return Function as defined by json */ public static MBFunction create(JSONObject json) { return null; } // // Color // /** * GeoTools {@link Expression} from json definition that evaluates to a color, used for * properties such as 'color' and 'fill-color'. * <p> * This is the same as {@link #numeric()} except we can make some assumptions about the values * (converting hex to color, looking up color names). * </p> * <ul> * <li>{@link FunctionType#IDENTITY}: input is directly converted to a color, providing a way to process attribute data * into colors.</li> * <li>{@link FunctionType#CATEGORICAL}: selects stop equal to input value</li> * <li>{@link FunctionType#INTERVAL}: selects stop less than numeric input value</li> * <li>{@link FunctionType#EXPONENTIAL}: interpolates an output color between two stops</li> * </ul> * If type is unspecified exponential is used as a default.</li> * * @return {@link Function} (or identity {@link Expression} for the provided json) */ public Expression color(){ Expression value = input(); FunctionType type = getTypeWithDefault(Color.class); if (type == FunctionType.EXPONENTIAL) { double base = parse.optional(Double.class, json, "base", 1.0 ); if (base == 1.0) { return colorGenerateInterpolation(value); } else { return colorGenerateExponential(value, base); } } if (type == null || type == FunctionType.CATEGORICAL) { return colorGenerateRecode(value); } else if (type == FunctionType.INTERVAL) { return colorGenerateCategorize(value); } else if (type == FunctionType.IDENTITY) { return withFallback(ff.function("css", value)); // force conversion of CSS color names } throw new UnsupportedOperationException("Color unavailable for '"+type+"' function"); } /** * Generates a color expression for the output of this {@link MBFunction} (as a {@link MBFunction.FunctionType#CATEGORICAL} function), based on the provided input Expression. * * @param expression The expression for the function input * @return The expression for the output of this function (as a {@link MBFunction.FunctionType#CATEGORICAL} function) */ private Expression colorGenerateCategorize(Expression expression) { return generateCategorize(expression, (value, stop)->{ Expression color = parse.color((String)value); if (color == null) { throw new MBFormatException("Could not convert stop "+stop+" color "+value+" into a color"); } return color; }); } /** * Use Recode function to implement {@link FunctionType#CATEGORICAL}. * <p> * * @param input input expression * @return recode function */ private Expression colorGenerateRecode(Expression input) { List<Expression> parameters = new ArrayList<>(); parameters.add(input); for (Object obj : getStops()) { JSONArray entry = parse.jsonArray(obj); Object stop = entry.get(0); Object value = entry.get(1); Expression color = parse.color((String)value); // handles web colors if( color == null ){ throw new MBFormatException("Could not convert stop "+stop+" color "+value+" into a color"); } parameters.add(ff.literal(stop)); parameters.add(color); } return withFallback(ff.function("Recode", parameters.toArray(new Expression[parameters.size()]))); } /** * Generates a color expression for the output of this {@link MBFunction} (as a interpolate function), based on the provided input Expression. * * @param expression The expression for the function input * @return The expression for the output of this function (as an interpolate function) */ private Expression colorGenerateInterpolation(Expression expression) { List<Expression> parameters = new ArrayList<>(); parameters.add(expression); for (Object obj : getStops()) { JSONArray entry = parse.jsonArray(obj); Object stop = entry.get(0); Object value = entry.get(1); Expression color = parse.color((String)value); // handles web colors if( color == null ){ throw new MBFormatException("Could not convert stop "+stop+" color "+value+" into a color"); } parameters.add(ff.literal(stop)); parameters.add(color); } parameters.add(ff.literal("color")); return withFallback(ff.function("Interpolate", parameters.toArray(new Expression[parameters.size()]))); } /** * Generates a color expression for the output of this {@link MBFunction} (as an exponential function), based on the provided input Expression. * * @param expression The expression for the function input * @return The expression for the output of this function (as an exponential function) */ private Expression colorGenerateExponential(Expression expression, double base) { List<Expression> parameters = new ArrayList<>(); parameters.add(expression); parameters.add(ff.literal(base)); for (Object obj : getStops()) { JSONArray entry = parse.jsonArray(obj); Object stop = entry.get(0); Object value = entry.get(1); Expression color = parse.color((String) value); if (color == null) { throw new MBFormatException( "Could not convert stop " + stop + " color " + value + " into a color"); } parameters.add(ff.literal(stop)); parameters.add(color); } return withFallback(ff.function("Exponential", parameters.toArray(new Expression[parameters.size()]))); } // // Numeric // /** * GeoTools {@link Expression} from json definition that evaluates to a numeric, used for * properties such as 'line-width' and 'opacity'. * <p> * This is the same as {@link #color()} except we can make some assumptions about the values * (converting "50%" to 0.5). * </p> * <ul> * <li>{@link FunctionType#IDENTITY}: input is directly converted to a numeric output</li> * <li>{@link FunctionType#CATEGORICAL}: selects stop equal to input, and returns stop value as a number</li> * <li>{@link FunctionType#INTERVAL}: selects stop less than numeric input, and returns stop value as a number</li> * <li>{@link FunctionType#EXPONENTIAL}: interpolates a numeric output between two stops</li> * </ul> * If type is unspecified exponential is used as a default.</li> * * @return {@link Function} (or identity {@link Expression} for the provided json) */ public Expression numeric() { Expression input = input(); FunctionType type = getTypeWithDefault(Number.class); if (type == FunctionType.EXPONENTIAL) { double base = parse.optional(Double.class, json, "base", 1.0 ); if (base == 1.0) { return numericGenerateInterpolation(input); } else { return numericGenerateExponential(input, base); } } if (type == FunctionType.CATEGORICAL) { return generateRecode(input); } else if (type == FunctionType.INTERVAL) { return generateCategorize(input); } else if (type == FunctionType.IDENTITY) { return withFallback(input); } throw new UnsupportedOperationException("Numeric unavailable for '"+type+"' function"); } /** * Used to calculate a numeric value. * <p> * Example adjusts circle size between 2 and 180 pixels when zooming between levels 12 and 22. * <pre><code>'circle-radius': { * 'stops': [[12, 2], [22, 180]] * }</pre></code> * * @param input The expression for the function input * @return The expression for the output of this function (as an interpolate function) */ private Expression numericGenerateInterpolation(Expression input) { List<Expression> parameters = new ArrayList<>(); parameters.add(input); for (Object obj : getStops()) { JSONArray entry = parse.jsonArray(obj); Object stop = entry.get(0); Object value = entry.get(1); if (value == null || !(value instanceof Number)) { throw new MBFormatException( "Could not convert stop " + stop + " color " + value + " into a numeric"); } parameters.add(ff.literal(stop)); parameters.add(ff.literal(value)); } parameters.add(ff.literal("numeric")); return withFallback(ff.function("Interpolate", parameters.toArray(new Expression[parameters.size()]))); } /** * Used to calculate a numeric value. * <p> * Example adjusts circle size between 2 and 180 pixels when zooming between levels 12 and 22. * <pre><code>'circle-radius': { * 'base': 1.75, * 'stops': [[12, 2], [22, 180]] * }</pre></code> * * @param input The expression for the function input * @param base The base of the exponential interpolation * @return The expression for the output of this function (as an exponential function) */ private Expression numericGenerateExponential (Expression input, double base) { List<Expression> parameters = new ArrayList<>(); parameters.add(input); parameters.add(ff.literal(base)); for (Object obj : getStops()) { JSONArray entry = parse.jsonArray(obj); Object stop = entry.get(0); Object value = entry.get(1); if (value == null || !(value instanceof Number)) { throw new MBFormatException( "Could not convert stop " + stop + " color " + value + " into a numeric"); } parameters.add(ff.literal(stop)); parameters.add(ff.literal(value)); } return withFallback(ff.function("Exponential", parameters.toArray(new Expression[parameters.size()]))); } // // General Purpose // /** * GeoTools {@link Expression} from json definition. * <p> * Delegates handling of Color, Number and Enum - for generic values (such as String) the following are available: * <ul> * <li>{@link FunctionType#IDENTITY}: input is directly converted to a literal</li> * <li>{@link FunctionType#CATEGORICAL}: selects stop equal to input, and returns stop value as a literal</li> * <li>{@link FunctionType#INTERVAL}: selects stop less than numeric input, and returns stop value a literal</li> * </ul> * If type is unspecified interval is used as a default. * * @return {@link Function} (or identity {@link Expression} for the provided json) */ @SuppressWarnings("unchecked") public Expression function(Class<?> clazz){ // check for special cases if (clazz.isAssignableFrom(Color.class)) { return color(); } else if (clazz.isAssignableFrom(Number.class)) { return numeric(); } else if (clazz.isAssignableFrom(Enum.class)) { return enumeration((Class<? extends Enum<?>>) clazz); } Expression input = input(); FunctionType type = getTypeWithDefault(clazz); if( type == null || type == FunctionType.INTERVAL){ return generateCategorize(input); } else if( type == FunctionType.CATEGORICAL){ return generateRecode(input); } else if( type == FunctionType.IDENTITY){ return withFallback(input); } throw new UnsupportedOperationException("Function unavailable for '"+type+"' function with "+clazz.getSimpleName()); } private Expression generateCategorize(Expression expression) { return generateCategorize(expression, (value, stop)->ff.literal(value)); } /** * * Takes an expression and wraps it with a function that falls back to this {@link MBFunction}'s default return value. * * If the input expression evaluates to null, the wrapper function will return the fallback value instead. * * @param expression The expression to wrap with a fallback to this {@link MBFunction}'s return value. */ private Expression withFallback(Expression expression) { Object defaultValue = getDefault(); if (defaultValue != null) { return ff.function("DefaultIfNull", expression, ff.literal(defaultValue)); } else { return expression; } } /** * Generates an expression for the output of this {@link MBFunction} (as an {@link MBFunction.FunctionType#INTERVAL} function), based on the provided input Expression. * * Note: A mapbox "interval" function is implemented as a GeoTools "categorize" function, hence the name of this method. * * @param expression The expression for the function input * @param parseValue A function of two arguments (stopValue, stop) that parses the stop value into an Expression. * @return The expression for the output of this function (as a {@link MBFunction.FunctionType#INTERVAL} function) */ private Expression generateCategorize(Expression expression, java.util.function.BiFunction<Object, Object, Expression> parseValue) { JSONArray stopsJson = getStops(); List<Expression> parameters = new ArrayList<>(stopsJson.size()*2+3); // each stop is 2, plus property name, leading interval value, and "succeeding" parameters.add(expression); for (int i = 0; i < stopsJson.size(); i++) { JSONArray entry = parse.jsonArray(stopsJson.get(i)); Object stop = entry.get(0); Expression value = parseValue.apply(entry.get(1), stop); if (i == 0) { // CategorizeFunction expects there to be a leading value for inputs < firstStopThreshold. // But the MapBox spec does not define the expected behavior in that case. // (spec: "functions return the output value of the stop just less than the function input.") // Return the default value (if any), otherwise the first stop's value. Expression initialValue; Object defaultValue = getDefault(); if (defaultValue != null) { initialValue = ff.literal(defaultValue); } else { initialValue = value; // The first stop's value } parameters.add(initialValue); } parameters.add(ff.literal(stop)); parameters.add(value); } parameters.add(ff.literal("succeeding")); Function categorizeFunction = ff.function("Categorize", parameters.toArray(new Expression[parameters.size()])); return withFallback(categorizeFunction); } /** * Generates an expression for the output of this {@link MBFunction} (as a {@link MBFunction.FunctionType#CATEGORICAL} function), based on the provided input Expression. * * Note: A mapbox "categorical" function is implemented as a GeoTools "recode" function, hence the name of this method. * * @param input The expression for the function input * @return The expression for the output of this function (as a {@link MBFunction.FunctionType#CATEGORICAL} function) */ private Expression generateRecode(Expression input) { List<Expression> parameters = new ArrayList<>(); parameters.add(input); for (Object obj : getStops()) { JSONArray entry = parse.jsonArray(obj); Object stop = entry.get(0); Object value = entry.get(1); parameters.add(ff.literal(stop)); parameters.add(ff.literal(value)); } Function recodeFn = ff.function("Recode", parameters.toArray(new Expression[parameters.size()])); return withFallback(recodeFn); } // // Enumerations // /** * GeoTools {@link Expression} from json definition that evaluates to the provided Enum, used for * properties such as 'line-cap' and 'text-transform'. * <ul> * <li>{@link FunctionType#IDENTITY}: input is directly converted to an appropriate literal</li> * <li>{@link FunctionType#CATEGORICAL}: selects stop equal to input, and returns stop value as a literal</li> * <li>{@link FunctionType#INTERVAL}: selects stop less than numeric input, and returns stop value a literal</li> * </ul> * If type is unspecified internval is used as a default.</li> * * @return {@link Function} (or identity {@link Expression} for the provided json) */ public Expression enumeration(Class<? extends Enum<?>> enumeration) { Expression input = input(); FunctionType type = getTypeWithDefault(Enumeration.class); if (type == FunctionType.INTERVAL) { return enumGenerateCategorize(input,enumeration); } else if (type == FunctionType.CATEGORICAL) { return enumGenerateRecode(input,enumeration); } else if (type == FunctionType.IDENTITY) { return withFallback(enumGenerateIdentity(input, enumeration)); } throw new UnsupportedOperationException("Unable to support '"+type+"' function for "+enumeration.getSimpleName()); } /** * Generates an expression (based on a mapbox enumeration property) for the output of this {@link MBFunction} (as a {@link MBFunction.FunctionType#CATEGORICAL} function), based on the provided input Expression. * * Note: A mapbox "categorical" function is implemented as a GeoTools "recode" function, hence the name of this method. * * @param input The expression for the function input * @param enumeration The type of the enumeration for the mapbox style property * @return The expression for the output of this function (as a {@link MBFunction.FunctionType#CATEGORICAL} function) */ private Expression enumGenerateRecode(Expression input, Class<? extends Enum<?>> enumeration) { List<Expression> parameters = new ArrayList<>(); parameters.add(input); for (Object obj : getStops()) { JSONArray entry = parse.jsonArray(obj); Object stop = entry.get(0); Object value = entry.get(1); parameters.add(ff.literal(stop)); parameters.add(parse.constant(value, enumeration)); } return withFallback(ff.function("Recode", parameters.toArray(new Expression[parameters.size()]))); } /** * Generates an expression (based on a mapbox enumeration property) for the output of this {@link MBFunction} (as a {@link MBFunction.FunctionType#INTERVAL} function), based on the provided input Expression. * * Note: A mapbox "interval" function is implemented as a GeoTools "categorize" function, hence the name of this method. * * @param input The expression for the function input * @param enumeration The type of the enumeration for the mapbox style property * @return The expression for the output of this function (as a {@link MBFunction.FunctionType#INTERVAL} function) */ private Expression enumGenerateCategorize(Expression input, Class<? extends Enum<?>> enumeration) { return withFallback(generateCategorize(input,(value, stop)->parse.constant(value, enumeration))); } /** * Generates an expression (based on a mapbox enumeration property) for the output of this {@link MBFunction} (as a {@link MBFunction.FunctionType#IDENTITY} function), based on the provided input Expression. * * @param input The expression for the function input * @param enumeration The type of the enumeration for the mapbox style property * @return The expression for the output of this function (as a {@link MBFunction.FunctionType#IDENTITY} function) */ private Expression enumGenerateIdentity(Expression input, Class<? extends Enum<?>> enumeration) { // this is an interesting challenge, we need to generate a recode mapping // mapbox constants defined by the enum, to appropriate geotools literals List<Expression> parameters = new ArrayList<>(); parameters.add(input); for (Enum<?> constant : enumeration.getEnumConstants()) { Object value = constant.name().toLowerCase(); parameters.add(ff.literal(value)); parameters.add(parse.constant(value, enumeration)); } return withFallback(ff.function("Recode", parameters.toArray(new Expression[parameters.size()]))); } /** * <p> * Returns true if this function's stop values are all arrays. * </p> * * <p> * For example, the following is an array function: * </p> * * <pre> * * "{'property':'temperature', * 'type':'exponential', * 'base':1.5, * 'stops': [ * // [stopkey, stopValueArray] * [0, [0,10]], * [100, [2,15]] * ] * }" * </pre> * * @return true if this function's stop values are all arrays. */ public boolean isArrayFunction() { if (getStops() == null) { return false; } // If any of the stops is not array-valued, return false. for (Object o : getStops()) { if (!(o instanceof JSONArray)) { return false; } else { JSONArray stop = (JSONArray) o; if (stop.size() != 2 || !(stop.get(1) instanceof JSONArray)) { return false; } } } return true; } /** * <p> * Splits an array function into multiple functions, one for each dimension in the function's stop value arrays. * </p> * * <p> * For example, for the following array function: * </p> * * <pre> * * "{'property':'temperature', * 'type':'exponential', * 'base':1.5, * 'stops': [ * // [stopkey, stopValueArray] * [0, [0,10]], * [100, [2,15]] * ] * }" * </pre> * * <p> * This method would split the above function into the following two functions: * </p> * * <p> * "X" Function: * </p> * * <pre> * * "{'property':'temperature', * 'type':'exponential', * 'base':1.5, * 'stops': [ * [0, 0], * [100, 2] * ] * }" * </pre> * * <p> * And "Y" Function: * </p> * * <pre> * * "{'property':'temperature', * 'type':'exponential', * 'base':1.5, * 'stops': [ * [0, 10], * [100, 15] * ] * }" * </pre> * * @return A list of {@link MBFunctions}, one for each dimension in the stop value array. */ public List<MBFunction> splitArrayFunction() throws ParseException { JSONArray arr = getStops(); // No need to split if there are no stops. if (arr.size() == 0) { return Arrays.asList(this); } // Parse the stops List<MBArrayStop> parsedStops = new ArrayList<>(); for (Object o : arr) { if (o instanceof JSONArray) { parsedStops.add(new MBArrayStop((JSONArray) o)); } else { throw new MBFormatException( "Exception handling array function: encountered non-array stop value."); } } // Make sure that all the stop value arrays have the same number of dimensions int dimensionCount = parsedStops.get(0).getStopValueCount(); boolean allStopsSameDimension = parsedStops.stream() .allMatch(stop -> stop.getStopValueCount() == dimensionCount); if (!allStopsSameDimension) { throw new MBFormatException( "Exception handling array function: all stops arrays must have the same length."); } // Make sure that the default value also has the same number of dimensions JSONArray defaultStopValues = null; if (getDefault() != null) { Object def = getDefault(); if ((def instanceof JSONArray) && ((JSONArray) def).size() == dimensionCount) { defaultStopValues = (JSONArray) def; } else { throw new MBFormatException( "Exception handling array function: the default value must also be an array of length " + dimensionCount); } } // Split the function into N functions, one for each dimension in the stop array values. List<MBFunction> functions = new ArrayList<>(); for (int i = 0; i < dimensionCount; i++) { final Integer n = i; JSONArray newStops = parsedStops.stream().map(stop -> stop.reducedToIndex(n)) .collect(Collectors.toCollection(JSONArray::new)); JSONObject newObj = (JSONObject) parser.parse(json.toJSONString()); newObj.put("stops", newStops); if (defaultStopValues != null) { newObj.put("default", defaultStopValues.get(n)); } MBFunction reduced = new MBFunction(newObj); functions.add(reduced); } return functions; } }