diff --git a/debug/expressions.html b/debug/expressions.html new file mode 100644 index 00000000000..e4f504b90c0 --- /dev/null +++ b/debug/expressions.html @@ -0,0 +1,183 @@ + + + + Mapbox GL JS debug page + + + + + + + +
+
+ + + + + + + + + diff --git a/docs/style-spec/expressions.md b/docs/style-spec/expressions.md new file mode 100644 index 00000000000..9bb4f46fc98 --- /dev/null +++ b/docs/style-spec/expressions.md @@ -0,0 +1,139 @@ +**NOTE: Consider the contents of this doc as a proposed replacement for the "Function" section of the style spec docs. Drafting it here rather than in the HTML doc so that it's easier to read/comment on.** + +# Functions + +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. + +## Property functions + +

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. Note that support for property functions is not available across all properties and platforms at this time.

+ +`expression` +_Required [expression value](#Expressions)_ +A property expression defines how one or more feature property values are combined using logical, mathematical, string, or color operations to produce the appropriate style value. See [Expressions](#Expressions) for details. + +```js +{ + "circle-color": { + "expression": [ + 'rgb', + // red is higher when feature.properties.temperature is higher + ["number_data", "temperature"], + 0, + // blue is higher when feature.properties.temperature is lower + ["-", 100, ["number_data", "temperature"]] + ] + } +} +``` + + +## Zoom functions + +**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. + +`stops` +_Required array_ +Zoom functions are defined in terms of input values and output values. A set of one input value and one output value is known as a "stop." Each stop is thus an array with two elements: the first is a zoom level; the second is either a style value or a property function. Note that support for property functions is not yet complete. + +`base` +_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. + +`type` +_Optional enum. One of exponential, interval._ + - `exponential` 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 , the "exponential" symbol. + - `interval` 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 , the "interval" symbol. + + +### Example: a zoom-only function. + +```js +{ + "circle-radius": { + "stops": [ + + // zoom is 5 -> circle radius will be 1px + [5, 1], + + // zoom is 10 -> circle radius will be 2px + [10, 2] + + ] + } +} +``` + +### Example: a zoom-and-property function + +Using property functions as the output value for one or more zoom stops allows +the appearance of a map feature to change with both the zoom level _and_ the +feature's properties. + +```js +{ + "circle-radius": { + "stops": [ + // zoom is 0 and "rating" is 0 -> circle radius will be 0px + // zoom is 0 and "rating" is 5 -> circle radius will be 5px + [0, { "expression": [ "number_data", "rating" ] }] + + // zoom is 20 and "rating" is 0 -> circle radius will be 4 * 0 = 0px + // zoom is 20 and "rating" is 5 -> circle radius will be 4 * 5 = 20px + [20, { "expression": [ "*", 4, ["number_data", "rating"] ] }] + ] + } +} +``` + + +## Property Expressions + +Property expressions are represented using a Lisp-like structured syntax tree. + +**Constants:** +- `[ "ln2" ]` +- `[ "pi" ]` +- `[ "e" ]` + +**Literals:** +- JSON string / number / boolean literal + +**Property lookup:** +- Feature property: + - `[ "number_data", key_expr ]` reads `feature.properties[key_expr]`, coercing it to a number if necessary. + - `[ "string_data", key_expr ]` reads `feature.properties[key_expr]`, coercing it to a string if necessary. + - `[ "boolean_data", key_expr ]` reads `feature.properties[key_expr]`, coercing it to a boolean if necessary, with `0`, `''`, `null`, and missing properties mapping to `false`, and all other values mapping to `true`. + - `[ "has", key_expr ]` returns `true` if the property is present, false otherwise. + - `[ "typeof", key_expr ]` yields the data type of `feature.properties[key_expr]`: one of `'string'`, `'number'`, `'boolean'`, `'object'`, `'array'`, or, in the case that the property is not present, `'none'`. +- `[ "geometry_type" ]` returns the value of `feature.geometry.type`. +- `[ "string_id" ]`, `[ "number_id" ]` returns the value of `feature.id`. + +**Decision:** +- `["if", boolean_expr, expr_if_true, expr_if_false]` +- `["switch", [[bool_expr1, result_expr1], [bool_expr2, result_expr2], ...], default_result_expr]` +- `["match", input_expr, [[test_expr1, result_expr1], [test_expr2, result_expr2]], default_result_expr]` +- `["interval", numeric_expr, [lbound_expr1, result_expr1], [lbound_expr2, result_expr2], ...]` + +**Comparison and boolean operations:** +- `[ "==", expr1, expr2]` (similar for `!=`) +- `[ ">", lhs_expr, rhs_expr ]` (similar for <, >=, <=) +- `[ "&&", boolean_expr1, boolean_expr2, ... ]` (similar for `||`) +- `[ "!", boolean_expr]` + +**String:** +- `["concat", expr1, expr2, …]` +- `["upcase", string_expr]`, `["downcase", string_expr]` + +**Numeric:** +- +, -, \*, /, %, ^ (e.g. `["+", expr1, expr2, expr3, …]`, `["-", expr1, expr2 ]`, etc.) +- log10, ln, log2 +- sin, cos, tan, asin, acos, atan +- ceil, floor, round, abs +- min, max +- `['linear', x, [ x1, y1 ], [ x2, y2 ] ]` - returns the output of the linear function determined by `(x1, y1)`, `(x2, y2)`, evaluated at `x` + +**Color:** +- rgb, hsl, hcl, lab, hex, (others?) +- `["color", color_name_expr]` + diff --git a/src/index.js b/src/index.js index 20ecc385f25..a07a676a62b 100644 --- a/src/index.js +++ b/src/index.js @@ -29,6 +29,8 @@ mapboxgl.supported = require('./util/browser').supported; const config = require('./util/config'); mapboxgl.config = config; +mapboxgl.createFunction = require('./style-spec/function'); + const rtlTextPlugin = require('./source/rtl_text_plugin'); mapboxgl.setRTLTextPlugin = rtlTextPlugin.setRTLTextPlugin; diff --git a/src/style-spec/function/expression.js b/src/style-spec/function/expression.js new file mode 100644 index 00000000000..f9b482a86f4 --- /dev/null +++ b/src/style-spec/function/expression.js @@ -0,0 +1,446 @@ +'use strict'; + +const assert = require('assert'); + +const Type = { + None: 'none', + Any: 'any', + Number: 'number', + String: 'string', + Boolean: 'boolean', + Color: 'color' +}; + +class NArgs { + constructor(itemType) { + this.itemType = itemType; + this.isNArgs = true; + } + + toString() { + return `${this.itemType}, ${this.itemType}, ...`; + } +} + +Type.NArgs = { + none: new NArgs(Type.None), + any: new NArgs(Type.Any), + number: new NArgs(Type.Number), + string: new NArgs(Type.String), + boolean: new NArgs(Type.Boolean), + color: new NArgs(Type.Color) +}; + +module.exports = compileExpression; + +/** + * + * Given a style function expression object, returns: + * ``` + * { + * isFeatureConstant: boolean, + * isZoomConstant: boolean, + * expressionString: string, + * function: Function, + * errors: Array<{expression, error}> + * } + * ``` + * + * @private + */ +function compileExpression(expr) { + const compiled = compile(expr); + if (compiled.errors.length === 0) { + compiled.function = new Function('mapProperties', 'feature', ` +mapProperties = mapProperties || {}; +feature = feature || {}; +var props = feature.properties || {}; +return (${compiled.expressionString}) +`); + } + return compiled; +} + +const functions = { + 'ln2': { + input: [], + output: Type.Number, + }, + 'pi': { + input: [], + output: Type.Number, + }, + 'e': { + input: [], + output: Type.Number, + }, + 'zoom': { + input: [], + output: Type.Number, + }, + 'boolean_data': { + input: [Type.String], + output: Type.Boolean, + }, + 'string_data': { + input: [Type.String], + output: Type.String, + }, + 'number_data': { + input: [Type.String], + output: Type.Number, + }, + 'has': { + input: [Type.String], + output: Type.Boolean + }, + 'typeof': { + input: [Type.String], + output: Type.String + }, + 'geometry_type': { + input: [], + output: Type.String + }, + 'string_id': { + input: [], + output: Type.String + }, + 'number_id': { + input: [], + output: Type.Number + }, + '+': { + input: [Type.Number, Type.Number], + output: Type.Number + }, + '*': { + input: [Type.Number, Type.Number], + output: Type.Number + }, + '-': { + input: [Type.Number, Type.Number], + output: Type.Number + }, + '/': { + input: [Type.Number, Type.Number], + output: Type.Number + }, + '^': { + input: [Type.Number, Type.Number], + output: Type.Number + }, + '%': { + input: [Type.Number, Type.Number], + output: Type.Number + }, + 'log10': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'ln': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'log2': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'sin': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'cos': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'tan': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'asin': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'acos': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'atan': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'ceil': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'floor': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'round': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'abs': { + input: [Type.Number], + output: Type.Number, + math: true + }, + 'min': { + input: [Type.NArgs[Type.Number]], + output: Type.Number, + math: true + }, + 'max': { + input: [Type.NArgs[Type.Number]], + output: Type.Number, + math: true + }, + '==': { + input: [Type.Any, Type.Any], + output: Type.Boolean + }, + '!=': { + input: [Type.Any, Type.Any], + output: Type.Boolean + }, + '>': { + input: [Type.Any, Type.Any], + output: Type.Boolean + }, + '<': { + input: [Type.Any, Type.Any], + output: Type.Boolean + }, + '>=': { + input: [Type.Any, Type.Any], + output: Type.Boolean + }, + '<=': { + input: [Type.Any, Type.Any], + output: Type.Number + }, + '&&': { + input: [Type.Boolean, Type.Boolean], + output: Type.Boolean + }, + '||': { + input: [Type.Boolean, Type.Boolean], + output: Type.Boolean + }, + '!': { + input: [Type.Boolean], + output: [Type.Boolean] + }, + 'concat': { + input: [Type.NArgs[Type.Any]], + output: Type.String + }, + 'upcase': { + input: [Type.String], + output: Type.String + }, + 'downcase': { + input: [Type.String], + output: Type.String + }, + 'rgb': { + input: [Type.Number, Type.Number, Type.Number], + output: Type.Color + }, + 'rgba': { + input: [Type.Number, Type.Number, Type.Number, Type.Number], + output: Type.Color + }, + 'hsl': { + input: [Type.Number, Type.Number, Type.Number], + output: Type.Color + }, + 'hsla': { + input: [Type.Number, Type.Number, Type.Number, Type.Number], + output: Type.Color + }, + 'if': { + input: [Type.Boolean, Type.Any, Type.Any], + output: null // determined at type-check time + } +}; + +function compile(expr) { + if (!expr) return { + expressionString: 'undefined', + isFeatureConstant: true, + isZoomConstant: true, + type: Type.None, + errors: [] + }; + + if (typeof expr === 'string') return { + expressionString: JSON.stringify(expr), + isFeatureConstant: true, + isZoomConstant: true, + type: Type.String, + errors: [] + }; + + if (typeof expr === 'number') return { + expressionString: JSON.stringify(expr), + isFeatureConstant: true, + isZoomConstant: true, + type: Type.Number, + errors: [] + }; + + if (typeof expr === 'boolean') return { + expressionString: JSON.stringify(expr), + isFeatureConstant: true, + isZoomConstant: true, + type: Type.Boolean, + errors: [] + }; + + let compiled; + const errors = []; + + assert(Array.isArray(expr)); + const op = expr[0]; + const argExpressions = expr.slice(1).map(compile); + const args = argExpressions.map(s => `(${s.expressionString})`); + + if (!functions[op]) { + errors.push({ expression: expr, error: `Unknown function ${op}`}); + } + + const type = checkType(expr, argExpressions.map(e => e.type), errors); + + if (argExpressions.some(s => !s.expressionString)) { + return { errors, expression: expr }; + } + + let isFeatureConstant = argExpressions.reduce((memo, e) => memo && e.isFeatureConstant, true); + let isZoomConstant = argExpressions.reduce((memo, e) => memo && e.isZoomConstant, true); + + if (op === 'e') { + compiled = `Math.E`; + } else if (op === 'ln2') { + compiled = `Math.LN2`; + } else if (op === 'pi') { + compiled = `Math.PI`; + } else if (op === 'number_data') { + compiled = `Number(props[${args[0]}])`; + isFeatureConstant = false; + } else if (op === 'string_data') { + compiled = `String(props[${args[0]}] || '')`; + isFeatureConstant = false; + } else if (op === 'boolean_data') { + compiled = `Boolean(props[${args[0]}])`; + isFeatureConstant = false; + } else if (op === 'typeof') { + compiled = ` + !(${args[0]} in props) ? 'none' + : typeof props[${args[0]}] === 'number' ? 'number' + : typeof props[${args[0]}] === 'string' ? 'string' + : typeof props[${args[0]}] === 'boolean' ? 'boolean' + : Array.isArray(props[${args[0]}]) ? 'array' + : 'object' + `; + isFeatureConstant = false; + } else if (op === 'has') { + compiled = `${args[0]} in props`; + isFeatureConstant = false; + } else if (op === 'geometry_type') { + compiled = `feature.geometry ? feature.geometry.type : undefined`; + } else if (op === 'string_id') { + compiled = 'String(feature.id || \'\')'; + } else if (op === 'number_id') { + compiled = 'Number(feature.id)'; + } else if (op === 'zoom') { + compiled = `mapProperties.zoom`; + isZoomConstant = false; + } else if (op === 'concat') { + compiled = `[${args.join(',')}].join('')`; + } else if (op === 'upcase') { + compiled = `String(${args[0]}).toUpperCase()`; + } else if (op === 'downcase') { + compiled = `String(${args[0]}).toLowerCase()`; + } else if (op === 'if') { + compiled = `${args[0]} ? ${args[1]} : ${args[2]}`; + } else if (op === '^') { + compiled = `Math.pow(${args[0]}, ${args[1]})`; + } else if (op === 'ln') { + compiled = `Math.log(${args[0]})`; + } else if (op === '!') { + compiled = `!(${args[0]})`; + } else if (functions[op].math) { + compiled = `Math.${op}(${args.join(', ')})`; + } else if (op === 'rgb' || op === 'rgba' || op === 'hsl' || op === 'hsla') { + compiled = `"${op}(" + ${args.join(' + "," + ')} + ")"`; + } else { + compiled = args.join(op); + } + + return { + expressionString: compiled, + errors, + isFeatureConstant, + isZoomConstant, + type + }; +} + +function checkType(expr, argTypes, errors) { + const op = expr[0]; + const input = functions[op].input; + let i = 0; + for (const t of input) { + if (t.isNArgs) { + while (i < argTypes.length) { + if (!match(t.itemType, argTypes[i])) + errors.push({expression: expr, error: `Expected ${t} but found ${argTypes[i]}`}); + i++; + } + } else { + if (!match(t, argTypes[i])) + errors.push({expression: expr, error: `Expected ${t} but found ${argTypes[i]}`}); + i++; + } + } + + if (op === 'if') { + if (!match(argTypes[1], argTypes[2])) + errors.push({expression: expr, error: `Expected both branches of 'if' to have the same type, but ${argTypes[1]} and ${argTypes[2]} do not match.`}); + return argTypes[1]; + } else if ( + op === '==' || + op === '!=' || + op === '>' || + op === '<' || + op === '>=' || + op === '<=' + ) { + if (!match(argTypes[0], argTypes[1])) + errors.push({expression: expr, error: `Comparison operator ${op} requires two expressions of matching types, but ${argTypes[0]} and ${argTypes[1]} do not match.`}); + } + + return functions[op].output; + + function match(t1, t2) { + return t1 === Type.Any || + t2 === Type.Any || + t1 === t2; + } +} + diff --git a/src/style-spec/function/index.js b/src/style-spec/function/index.js index 1a72e9de524..cc6bcda4a2d 100644 --- a/src/style-spec/function/index.js +++ b/src/style-spec/function/index.js @@ -5,6 +5,7 @@ const parseColor = require('../util/parse_color'); const extend = require('../util/extend'); const getType = require('../util/get_type'); const interpolate = require('../util/interpolate'); +const compileExpression = require('./expression'); function identityFunction(x) { return x; @@ -24,7 +25,15 @@ function createFunction(parameters, propertySpec) { }; fun.isFeatureConstant = true; fun.isZoomConstant = true; - + } else if (parameters.type === 'expression') { + const compiled = compileExpression(parameters.expression); + fun = function(zoom, featureProperties) { + const result = compiled.function({zoom}, {properties: featureProperties}); + return isColor ? parseColor(result) : result; + }; + fun.isFeatureConstant = compiled.isFeatureConstant; + fun.isZoomConstant = compiled.isZoomConstant; + fun.zoomInterpolationBase = parameters.zoomInterpolationBase; } else { const zoomAndFeatureDependent = parameters.stops && typeof parameters.stops[0][0] === 'object'; const featureDependent = zoomAndFeatureDependent || parameters.property !== undefined; @@ -248,7 +257,7 @@ function findStopLessThanOrEqualTo(stops, input) { } function isFunctionDefinition(value) { - return typeof value === 'object' && (value.stops || value.type === 'identity'); + return typeof value === 'object' && (value.stops || value.type === 'identity' || value.type === 'expression'); } /** diff --git a/src/style-spec/reference/v8.json b/src/style-spec/reference/v8.json index 2da93cc484b..5b41cffc8cc 100644 --- a/src/style-spec/reference/v8.json +++ b/src/style-spec/reference/v8.json @@ -1624,6 +1624,10 @@ "doc": "The geometry type for the filter to select." }, "function": { + "expression": { + "type": "*", + "doc": "A function expression" + }, "stops": { "type": "array", "doc": "An array of stops.", @@ -1635,6 +1639,12 @@ "minimum": 0, "doc": "The exponential base of the interpolation curve. It controls the rate at which the result increases. Higher values make the result increase more towards the high end of the range. With `1` the stops are interpolated linearly." }, + "zoomInterpolationBase": { + "type": "number", + "default": 2, + "minimum": 0, + "doc": "The exponential base used to interpolate expression-based functions that are zoom-and-property-dependent." + }, "property": { "type": "string", "doc": "The name of a feature property to use as the function input.", @@ -1654,6 +1664,9 @@ }, "categorical": { "doc": "Return the output value of the stop equal to the function input." + }, + "expression": { + "doc": "Return the output value of the given function expression." } }, "doc": "The interpolation strategy to use in function evaluation.", diff --git a/src/style-spec/validate/validate_function.js b/src/style-spec/validate/validate_function.js index a63ce3a2443..ebc095c2e63 100644 --- a/src/style-spec/validate/validate_function.js +++ b/src/style-spec/validate/validate_function.js @@ -39,7 +39,7 @@ module.exports = function validateFunction(options) { errors.push(new ValidationError(options.key, options.value, 'missing required property "property"')); } - if (functionType !== 'identity' && !options.value.stops) { + if (functionType !== 'identity' && functionType !== 'expression' && !options.value.stops) { errors.push(new ValidationError(options.key, options.value, 'missing required property "stops"')); } diff --git a/src/style/style_declaration.js b/src/style/style_declaration.js index 7c99bb44f23..3b5772b0c9a 100644 --- a/src/style/style_declaration.js +++ b/src/style/style_declaration.js @@ -24,18 +24,32 @@ class StyleDeclaration { if (!this.isFeatureConstant && !this.isZoomConstant) { this.stopZoomLevels = []; const interpolationAmountStops = []; - for (const stop of this.value.stops) { - const zoom = stop[0].zoom; - if (this.stopZoomLevels.indexOf(zoom) < 0) { - this.stopZoomLevels.push(zoom); - interpolationAmountStops.push([zoom, interpolationAmountStops.length]); + + let base; + if (this.value.type === 'expression') { + // generate "pseudo stops" for the function expression at + // integer zoom levels so that we can interpolate the + // render-time value the same way as for stop-based functions. + base = this.value.zoomInterpolationBase || 1; + for (let z = 0; z < 30; z++) { + this.stopZoomLevels.push(z); + interpolationAmountStops.push([z, interpolationAmountStops.length]); + } + } else { + base = this.value.base; + for (const stop of this.value.stops) { + const zoom = stop[0].zoom; + if (this.stopZoomLevels.indexOf(zoom) < 0) { + this.stopZoomLevels.push(zoom); + interpolationAmountStops.push([zoom, interpolationAmountStops.length]); + } } } this._functionInterpolationT = createFunction({ type: 'exponential', stops: interpolationAmountStops, - base: value.base + base: base }, { type: 'number' }); diff --git a/test/unit/style-spec/function/expression.test.js b/test/unit/style-spec/function/expression.test.js new file mode 100644 index 00000000000..ef9f47f9e0c --- /dev/null +++ b/test/unit/style-spec/function/expression.test.js @@ -0,0 +1,316 @@ +'use strict'; + +const test = require('mapbox-gl-js-test').test; +const createFunction = require('../../../../src/style-spec/function/expression'); + +test('expressions', (t) => { + t.test('literals', (t) => { + let f = createFunction(1).function; + t.equal(f({}, {}), 1); + f = createFunction("hi").function; + t.equal(f({}, {}), "hi"); + f = createFunction(true).function; + t.equal(f({}, {}), true); + + t.end(); + }); + + t.test('constants', (t) => { + let f = createFunction([ 'ln2' ]).function; + t.equal(f(), Math.LN2); + f = createFunction([ 'pi' ]).function; + t.equal(f(), Math.PI); + f = createFunction([ 'e' ]).function; + t.equal(f(), Math.E); + t.end(); + }); + + t.test('number_data', (t) => { + const f = createFunction(['number_data', 'x']).function; + t.equal(f({}, { properties: { x: 42 } }), 42); + t.equal(f({}, { properties: { x: '42' } }), 42); + t.ok(isNaN(f({}, {}))); + t.end(); + }); + + t.test('string_data', (t) => { + const f = createFunction(['string_data', 'x']).function; + t.equal(f({}, { properties: { x: 'hello' } }), 'hello'); + t.equal(f({}, { properties: { x: 42 } }), '42'); + t.equal(f({}, { properties: { x: true } }), 'true'); + t.equal(f({}, {}), ''); + t.end(); + }); + + t.test('boolean_data', (t) => { + const f = createFunction(['boolean_data', 'x']).function; + t.equal(f({}, { properties: { x: true } }), true); + t.equal(f({}, { properties: { x: false } }), false); + t.equal(f({}, { properties: { x: 'hello' } }), true); + t.equal(f({}, { properties: { x: 42 } }), true); + t.equal(f({}, { properties: { x: '' } }), false); + t.equal(f({}, { properties: { x: 0 } }), false); + t.equal(f({}, {}), false); + t.end(); + }); + + t.test('geometry_type', (t) => { + const f = createFunction(['geometry_type']).function; + t.equal(f({}, { geometry: { type: 'LineString' }}), 'LineString'); + t.equal(f(), undefined); + t.end(); + }); + + t.test('string_id', (t) => { + const f = createFunction(['string_id']).function; + t.equal(f({}, { id: 1 }), '1'); + t.equal(f(), ''); + t.end(); + }); + + t.test('number_id', (t) => { + const f = createFunction(['number_id']).function; + t.equal(f({}, { id: 1 }), 1); + t.ok(isNaN(f())); + t.end(); + }); + + t.test('has', (t) => { + const f = createFunction(['has', 'x']).function; + t.equal(f({}, { properties: { x: 'foo' } }), true); + t.equal(f({}, { properties: { x: 0 } }), true); + t.equal(f({}, { properties: { x: null } }), true); + t.equal(f({}, { properties: {} }), false); + t.end(); + }); + + t.test('typeof', (t) => { + const f = createFunction(['typeof', 'x']).function; + t.equal(f({}, { properties: { x: 'foo' } }), 'string'); + t.equal(f({}, { properties: { x: 0 } }), 'number'); + t.equal(f({}, { properties: { x: false } }), 'boolean'); + t.equal(f({}, { properties: { x: [] } }), 'array'); + t.equal(f({}, { properties: { x: {} } }), 'object'); + t.equal(f({}, { properties: {} }), 'none'); + t.end(); + }); + + t.test('zoom', (t) => { + const f = createFunction(['zoom']).function; + t.equal(f({ zoom: 7 }, {}), 7); + t.end(); + }); + + t.test('basic arithmetic', (t) => { + let f = createFunction([ '+', 1, 2 ]).function; + t.equal(f({}, {}), 3); + + f = createFunction([ '*', 2, ['number_data', 'x'] ]).function; + t.equal(f({}, { properties: { x: 42 } }), 84); + + f = createFunction([ '/', [ 'number_data', 'y' ], [ 'number_data', 'x' ] ]).function; + t.equal(f({}, { properties: { x: -1, y: 12 } }), -12); + + t.end(); + }); + + t.test('numeric comparison', (t) => { + let f = createFunction(['==', 1, ['number_data', 'x']]).function; + t.equal(f({}, {properties: {x: 1}}), true); + t.equal(f({}, {properties: {x: 2}}), false); + f = createFunction(['!=', 1, ['number_data', 'x']]).function; + t.equal(f({}, {properties: {x: 1}}), false); + t.equal(f({}, {properties: {x: 2}}), true); + f = createFunction(['>', 1, ['number_data', 'x']]).function; + t.equal(f({}, {properties: {x: 1}}), false); + t.equal(f({}, {properties: {x: 2}}), false); + t.equal(f({}, {properties: {x: 0}}), true); + f = createFunction(['<', 1, ['number_data', 'x']]).function; + t.equal(f({}, {properties: {x: 1}}), false); + t.equal(f({}, {properties: {x: 2}}), true); + t.equal(f({}, {properties: {x: 0}}), false); + f = createFunction(['>=', 1, ['number_data', 'x']]).function; + t.equal(f({}, {properties: {x: 1}}), true); + t.equal(f({}, {properties: {x: 2}}), false); + t.equal(f({}, {properties: {x: 0}}), true); + f = createFunction(['<=', 1, ['number_data', 'x']]).function; + t.equal(f({}, {properties: {x: 1}}), true); + t.equal(f({}, {properties: {x: 2}}), true); + t.equal(f({}, {properties: {x: 0}}), false); + + t.deepEqual( + createFunction(['==', 1, ['string_data', 'x']]).errors.map(e => e.error), + ['Comparison operator == requires two expressions of matching types, but number and string do not match.'] + ); + + t.end(); + }); + + t.test('string comparison', (t) => { + let f = createFunction(['==', 'abc', ['string_data', 'x']]).function; + t.equal(f({}, {properties: {x: 'abc'}}), true); + t.equal(f({}, {properties: {x: 'def'}}), false); + f = createFunction(['!=', 'abc', ['string_data', 'x']]).function; + t.equal(f({}, {properties: {x: 'abc'}}), false); + t.equal(f({}, {properties: {x: 'def'}}), true); + f = createFunction(['>', 'abc', ['string_data', 'x']]).function; + t.equal(f({}, {properties: {x: 'abc'}}), false); + t.equal(f({}, {properties: {x: 'def'}}), false); + t.equal(f({}, {properties: {x: 'aaa'}}), true); + f = createFunction(['<', 'abc', ['string_data', 'x']]).function; + t.equal(f({}, {properties: {x: 'abc'}}), false); + t.equal(f({}, {properties: {x: 'def'}}), true); + t.equal(f({}, {properties: {x: 'aaa'}}), false); + f = createFunction(['>=', 'abc', ['string_data', 'x']]).function; + t.equal(f({}, {properties: {x: 'abc'}}), true); + t.equal(f({}, {properties: {x: 'def'}}), false); + t.equal(f({}, {properties: {x: 'aaa'}}), true); + f = createFunction(['<=', 'abc', ['string_data', 'x']]).function; + t.equal(f({}, {properties: {x: 'abc'}}), true); + t.equal(f({}, {properties: {x: 'def'}}), true); + t.equal(f({}, {properties: {x: 'aaa'}}), false); + + t.deepEqual( + createFunction(['==', 'abc', ['number_data', 'x']]).errors.map(e => e.error), + ['Comparison operator == requires two expressions of matching types, but string and number do not match.'] + ); + + t.end(); + }); + + t.test('!', (t) => { + const f = createFunction(['!', ['==', ['number_data', 'x'], 1]]).function; + t.equal(f({}, {properties: {x: 1}}), false); + t.end(); + }); + + t.test('&&', (t) => { + const f = createFunction([ + '&&', + [ '==', [ 'number_data', 'x' ], 1 ], + [ '==', [ 'string_data', 'y' ], '2' ], + [ '==', [ 'string_data', 'z' ], '3' ] + ]).function; + t.equal(f({}, {properties: {x: 1, y: 2, z: 3}}), true); + t.equal(f({}, {properties: {x: 1, y: 0, z: 3}}), false); + t.end(); + }); + + t.test('||', (t) => { + const f = createFunction([ + '||', + [ '==', [ 'number_data', 'x' ], 1 ], + [ '==', [ 'string_data', 'y' ], '2' ], + [ '==', [ 'string_data', 'z' ], '3' ] + ]).function; + t.equal(f({}, {properties: {x: 1, y: 2, z: 3}}), true); + t.equal(f({}, {properties: {x: 1, y: 0, z: 3}}), true); + t.equal(f({}, {properties: {x: 0, y: 0, z: 0}}), false); + t.end(); + }); + + t.test('upcase', (t) => { + const f = createFunction(['upcase', ['string_data', 'x']]).function; + t.equal(f({}, {properties: {x: 'aBc'}}), 'ABC'); + t.end(); + }); + + t.test('downcase', (t) => { + const f = createFunction(['downcase', ['string_data', 'x']]).function; + t.equal(f({}, {properties: {x: 'AbC'}}), 'abc'); + t.end(); + }); + + t.test('concat', (t) => { + let f = createFunction(['concat', 'a', 'b', 'c']).function; + + t.equal(f(), 'abc'); + + f = createFunction([ + 'concat', ['string_data', 'name'], ' (', ['string_data', 'name_en'], ')' + ]).function; + + t.equal( + f({}, { properties: { name: 'B\'more', 'name_en': 'Baltimore' } }), + 'B\'more (Baltimore)' + ); + + f = createFunction(['concat', true, 1, 'foo']).function; + t.equal(f(), 'true1foo'); + + t.end(); + }); + + t.test('color functions', (t) => { + const f = createFunction([ + 'rgb', + [ '+', 128, [ '*', 10, ['number_data', 'x'] ] ], + [ '+', 128, [ '*', 10, ['number_data', 'y'] ] ], + 128 + ]).function; + t.equal(f({}, {properties: {x: -5, y: 5}}), 'rgb(78,178,128)'); + t.end(); + }); + + t.test('if', (t) => { + let f = createFunction([ + 'if', + [ 'has', 'x' ], + [ + '*', + ['number_data', 'y'], + ['number_data', 'x'] + ], + -1 + ]).function; + + t.equal(f({}, { properties: { x: -1, y: 12 } }), -12); + t.equal(f({}, { properties: { y: 12 } }), -1); + + t.deepEqual( + createFunction(['if', ['has', 'x'], 1, 'two']).errors.map(e => e.error), + ['Expected both branches of \'if\' to have the same type, but number and string do not match.'] + ); + + f = createFunction([ + 'if', [ '&&', [ 'has', 'name_en' ], ['has', 'name'] ], + [ + 'concat', + ['string_data', 'name'], + ' (', ['string_data', 'name_en'], ')' + ], + [ + 'if', [ '&&', ['has', 'name_fr'], ['has', 'name'] ], + [ + 'concat', + ['string_data', 'name'], + ' (', ['string_data', 'name_fr'], ')' + ], + [ + 'if', ['has', 'name'], + ['string_data', 'name'], + 'unnamed' + ] + ] + ]).function; + + t.equal(f({}, { properties: { name: 'Foo' } }), 'Foo'); + t.equal(f({}, { properties: { name: 'Illyphay', 'name_en': 'Philly' } }), + 'Illyphay (Philly)'); + t.equal(f({}, { properties: { name: 'Arispay', 'name_fr': 'Paris' } }), + 'Arispay (Paris)'); + t.equal(f({}, {}), 'unnamed'); + + t.end(); + }); + + t.test('math functions require numeric arguments', (t) => { + t.deepEqual( + createFunction(['+', '12', 6]).errors.map(e => e.error), + ['Expected number but found string'] + ); + t.end(); + }); + + t.end(); +});