diff --git a/.eslintrc b/.eslintrc index ab07a74e6ca..bad26cbe00c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -26,11 +26,12 @@ "no-eq-null": "off", "no-lonely-if": "off", "no-new": "off", + "no-unused-vars": ["error", {"argsIgnorePattern": "^_$"}], "no-var": "error", "no-warning-comments": "error", "object-curly-spacing": "off", "prefer-arrow-callback": "error", - "prefer-const": "error", + "prefer-const": ["error", {"destructuring": "all"}], "prefer-template": "error", "quotes": "off", "space-before-function-paren": "off", diff --git a/build/generate-flow-typed-style-spec.js b/build/generate-flow-typed-style-spec.js index 11b5a68e95d..e52d55a4554 100644 --- a/build/generate-flow-typed-style-spec.js +++ b/build/generate-flow-typed-style-spec.js @@ -130,29 +130,35 @@ declare type TransitionSpecification = { // Note: doesn't capture interpolatable vs. non-interpolatable types. declare type CameraFunctionSpecification = - | { type: 'exponential', stops: Array<[number, T]> } - | { type: 'interval', stops: Array<[number, T]> }; + | {| type: 'exponential', stops: Array<[number, T]> |} + | {| type: 'interval', stops: Array<[number, T]> |}; declare type SourceFunctionSpecification = - | { type: 'exponential', stops: Array<[number, T]>, property: string, default?: T } - | { type: 'interval', stops: Array<[number, T]>, property: string, default?: T } - | { type: 'categorical', stops: Array<[string | number | boolean, T]>, property: string, default?: T } - | { type: 'identity', property: string, default?: T }; + | {| type: 'exponential', stops: Array<[number, T]>, property: string, default?: T |} + | {| type: 'interval', stops: Array<[number, T]>, property: string, default?: T |} + | {| type: 'categorical', stops: Array<[string | number | boolean, T]>, property: string, default?: T |} + | {| type: 'identity', property: string, default?: T |}; declare type CompositeFunctionSpecification = - | { type: 'exponential', stops: Array<[{zoom: number, value: number}, T]>, property: string, default?: T } - | { type: 'interval', stops: Array<[{zoom: number, value: number}, T]>, property: string, default?: T } - | { type: 'categorical', stops: Array<[{zoom: number, value: string | number | boolean}, T]>, property: string, default?: T }; + | {| type: 'exponential', stops: Array<[{zoom: number, value: number}, T]>, property: string, default?: T |} + | {| type: 'interval', stops: Array<[{zoom: number, value: number}, T]>, property: string, default?: T |} + | {| type: 'categorical', stops: Array<[{zoom: number, value: string | number | boolean}, T]>, property: string, default?: T |}; + +declare type ExpressionFunctionSpecification = {| + expression: mixed +|} declare type PropertyValueSpecification = | T - | CameraFunctionSpecification; + | CameraFunctionSpecification + | ExpressionFunctionSpecification; declare type DataDrivenPropertyValueSpecification = | T | CameraFunctionSpecification | SourceFunctionSpecification - | CompositeFunctionSpecification; + | CompositeFunctionSpecification + | ExpressionFunctionSpecification; ${flowObjectDeclaration('StyleSpecification', spec.$root)} diff --git a/debug/circles.html b/debug/circles.html index 375a49b891c..caa5771b09d 100644 --- a/debug/circles.html +++ b/debug/circles.html @@ -42,21 +42,25 @@ "source": "circles", "paint": { "circle-radius": { - property: "mapbox", - stops: [ - [{ zoom: 0, value: 0 }, 2], - [{ zoom: 0, value: 100 }, 10], - [{ zoom: 6, value: 0 }, 20], - [{ zoom: 6, value: 100 }, 100] + "expression": [ + "curve", + ["linear"], + ["zoom"], + 0, + ["curve", ["linear"], ["number", ["get", "scalerank"]], 0, 2, 10, 10], + 6, + ["curve", ["linear"], ["number", ["get", "scalerank"]], 0, 20, 10, 100] ] }, "circle-color": { - property: "mapbox", - stops: [ - [{ zoom: 0, value: 0 }, 'red'], - [{ zoom: 0, value: 100 }, 'violet'], - [{ zoom: 6, value: 0 }, 'blue'], - [{ zoom: 6, value: 100 }, 'green'] + "expression": [ + "curve", + ["linear"], + ["zoom"], + 0, + ["curve", ["linear"], ["number", ["get", "scalerank"]], 0, ["to-color", "red"], 6, ["to-color", "green"]], + 6, + ["curve", ["linear"], ["number", ["get", "scalerank"]], 0, ["to-color", "green"], 6, ["to-color", "blue"]], ] }, "circle-pitch-scale": "map", diff --git a/docs/.eslintrc b/docs/.eslintrc index 79ad8a0c5e3..0b620fc837a 100644 --- a/docs/.eslintrc +++ b/docs/.eslintrc @@ -1,13 +1,5 @@ { - "parser": "espree", - "parserOptions": { - "ecmaVersion": 5 - }, - "env": { - "es6": false, - "browser": true - }, - "globals": { - "Uint8Array": true + "rules": { + "flowtype/require-valid-file-annotation": [0] } } diff --git a/docs/_posts/examples/.eslintrc b/docs/_posts/examples/.eslintrc index c33e331b715..05487f52657 100644 --- a/docs/_posts/examples/.eslintrc +++ b/docs/_posts/examples/.eslintrc @@ -1,11 +1,16 @@ { + "parser": "espree", + "parserOptions": { + "ecmaVersion": 5 + }, "plugins": ["html"], "globals": { "mapboxgl": true, "MapboxGeocoder": true, "MapboxDirections": true, "turf": true, - "d3": true + "d3": true, + "Uint8Array": true }, "rules": { "flowtype/require-valid-file-annotation": [0], @@ -17,6 +22,7 @@ "prefer-template": "off" }, "env": { + "es6": false, "browser": true } } diff --git a/docs/style-spec/_generate/expression-types.js b/docs/style-spec/_generate/expression-types.js new file mode 100644 index 00000000000..0b334cd058f --- /dev/null +++ b/docs/style-spec/_generate/expression-types.js @@ -0,0 +1,110 @@ +'use strict'; +require('flow-remove-types/register'); + +const toString = require('../../../src/style-spec/function/types').toString; +const CompoundExpression = require('../../../src/style-spec/function/compound_expression').CompoundExpression; + +// registers compound expressions +require('../../../src/style-spec/function/definitions'); + +const results = { + array: [{ + type: 'Array', + parameters: ['Value'], + }, { + type: 'Array', + parameters: [ + {name: 'type', type: '"String" | "Number" | "Boolean"'}, + 'Value' + ], + }, { + type: 'Array', + parameters: [ + {name: 'type', type: '"String" | "Number" | "Boolean"'}, + {name: 'N', type: 'Number (literal)'}, + 'Value' + ] + }], + at: [{ + type: 'T', + parameters: ['Number', 'Array'] + }], + case: [{ + type: 'T', + parameters: [{ repeat: ['Boolean', 'T'] }, 'T'] + }], + coalesce: [{ + type: 'T', + parameters: [{repeat: 'T'}] + }], + contains: [{ + type: 'Boolean', + parameters: ['T', 'Array | Array'] + }], + curve: [{ + type: 'T', + parameters: [ + {name: 'input', type: 'Number'}, + '["step"]', + 'T', + {repeat: ['Number', 'T']} + ] + }, { + type: 'T: Number, ', + parameters: [ + {name: 'input', type: 'Number'}, + {name: 'interpolation', type: '["step"] | ["linear"] | ["exponential", base] | ["cubic-bezier", x1, y1, x2, y2 ]'}, + {repeat: ['Number', 'T']} + ] + }], + let: [{ + type: 'T', + parameters: [{ repeat: ['String (alphanumeric literal)', 'any']}, 'T'] + }], + literal: [{ + type: 'Array', + parameters: ['[...] (JSON array literal)'] + }, { + type: 'Object', + parameters: ['{...} (JSON object literal)'] + }], + match: [{ + type: 'U', + parameters: [ + {name: 'input', type: 'T: Number (integer literal) | String (literal)'}, + {repeat: ['T | [T, T, ...]', 'U']}, + 'U' + ] + }], + var: [{ + type: 'the type of the bound expression', + parameters: ['previously bound variable name'] + }] +}; + +for (const name in CompoundExpression.definitions) { + const definition = CompoundExpression.definitions[name]; + if (Array.isArray(definition)) { + results[name] = [{ + type: toString(definition[0]), + parameters: processParameters(definition[1]) + }]; + } else { + results[name] = definition.overloads.map((o) => { + return { + type: toString(definition.type), + parameters: processParameters(o[0]) + }; + }); + } +} + +function processParameters(params) { + if (Array.isArray(params)) { + return params.map(toString); + } else { + return [{repeat: [toString(params.type)]}]; + } +} + +module.exports = results; diff --git a/docs/style-spec/_generate/expression.html b/docs/style-spec/_generate/expression.html new file mode 100644 index 00000000000..fa07d5ad5bd --- /dev/null +++ b/docs/style-spec/_generate/expression.html @@ -0,0 +1,12 @@ +
+ + <%= md("" + name + " " + (expressionDocs[name] ? ('
' + expressionDocs[name].doc) : '').trim()) %> +
+ <% for (const overload of expressionTypes[name]) { %> +
+{% highlight javascript %} +<%=renderSignature(name, overload) %> +{% endhighlight %} +
+ <% } %> +
diff --git a/docs/style-spec/_generate/generate.js b/docs/style-spec/_generate/generate.js index e76b572755e..2d17b24e9f9 100755 --- a/docs/style-spec/_generate/generate.js +++ b/docs/style-spec/_generate/generate.js @@ -9,8 +9,11 @@ var _ = require('lodash'); var remark = require('remark'); var html = require('remark-html'); +var expressionTypes = require('./expression-types'); + + function tmpl(x, options) { - return _.template(fs.readFileSync(path.join(__dirname, x), 'utf-8'), options); + return _.template(fs.readFileSync(path.join(__dirname, x), 'utf-8'), options); } var index = tmpl('index.html', { @@ -23,8 +26,47 @@ var index = tmpl('index.html', { return remark().use(html).process(markdown); } } + }), + expressions: Object.keys(expressionTypes).sort((a, b) => a.localeCompare(b)), + renderExpression: tmpl('expression.html', { + imports: { + _: _, + expressionDocs: ref['expression_name'].values, + expressionTypes: expressionTypes, + renderSignature: renderSignature, + md: function(markdown) { + return remark().use(html).process(markdown) + } + } }) } }); +function renderSignature (name, overload) { + name = JSON.stringify(name); + const maxLength = 80 - name.length - overload.type.length; + const params = renderParams(overload.parameters, maxLength); + return `[${name}${params}]: ${overload.type}`; +} + +function renderParams (params, maxLength) { + const result = ['']; + for (const t of params) { + if (typeof t === 'string') { + result.push(t); + } else if (t.name) { + result.push(`${t.name}: ${t.type}`); + } else if (t.repeat) { + const repeated = renderParams(t.repeat, Infinity); + result.push(`${repeated.slice(2)}${repeated}, ...`); + } + } + + // length of result = each (', ' + item) + const length = result.reduce((l, s) => l + s.length + 2, 0); + return (!maxLength || length <= maxLength) ? + result.join(', ') : + `${result.join(',\n ')}\n`; +} + fs.writeFileSync(path.join(__dirname, '../index.html'), index({ ref: ref })); diff --git a/docs/style-spec/_generate/index.html b/docs/style-spec/_generate/index.html index 7b18bc477e3..faa967f3951 100755 --- a/docs/style-spec/_generate/index.html +++ b/docs/style-spec/_generate/index.html @@ -58,6 +58,7 @@ - title: Number - title: Array - title: Function + - title: Expression - title: Filter --- @@ -828,67 +829,20 @@

Fun
- -
Required (except for identity functions) array.
-
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."
-
-
- -
Optional string.
-
If specified, the function will take the specified feature property as an input. See Zoom Functions and Property Functions for more information.
-
-
- -
Optional number. Default is <%= ref.function.base.default %>.
-
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.
-
-
- -
Optional enum. One of identity, exponential, interval, categorical.
-
-
identity
-
functions return their input as their output.
-
exponential
-
functions generate an output by interpolating between stops just less than and just greater than the - function input. The domain (input value) must be numeric, and the style property must support - interpolation. Style properties that support interpolation are marked marked with , the - "exponential" symbol, and exponential is the default function type for these properties.
-
interval
-
functions return the output value of the stop just less than the function input. The domain (input - value) must be numeric. Any style property may use interval functions. For properties marked with - , the "interval" - symbol, this is the default function type.
-
categorical
-
functions return the output value of the stop equal to the function input.
-
-
-
- -
A value to serve as a fallback function result when a value isn't otherwise available. It is used in the following circumstances:
-
    -
  • In categorical functions, when the feature value does not match any of the stop domain values.
  • -
  • In property and zoom-and-property functions, when a feature does not contain a value for the specified property.
  • -
  • 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).
  • -
  • In interval or exponential property and zoom-and-property functions, when the feature value is not numeric.
  • -
-
If no default is provided, the style property's default is used in these circumstances.
-
-
- -
Optional enum. One of rgb, lab, hcl.
-
The color space in which colors interpolated. Interpolating colors in perceptual color spaces like LAB and HCL tend to produce color ramps that look more consistent and produce colors that can be differentiated more easily than those interpolated in RGB space.
-
-
rgb
-
Use the RGB color space to interpolate color values
-
lab
-
Use the LAB color space to interpolate color values.
-
hcl
-
Use the HCL color space to interpolate color values, interpolating the Hue, Chroma, and Luminance channels individually.
-
+ +
Required expression.
+
+ An expression defines how one or more feature property values + and/or the current zoom level are combined using logical, + mathematical, string, or color operations to produce the + appropriate style value. See + expressions for syntax details. +
+

In prior versions of the style specification, functions were defined using stops, property, default values.

+ @@ -907,6 +861,13 @@

Fun

+ + + + + + + @@ -958,7 +919,7 @@

Fun

- + @@ -966,80 +927,104 @@

Fun

>= 2.0.0 >= 0.1.0
expressions>= 0.40.0Not yet supportedNot yet supportedNot yet supported
property >= 0.18.0
colorSpace>= 0.26.00.26.0 - 0.39.1 Not yet supported Not yet supported Not yet supported
-

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. Each stop is an array with two elements: the first is a zoom level and the second is a function output value.

+

A property function is any function defined using an expression that includes a reference to ["properties"]. Property functions allow the appearance of a map feature to change with its properties. They 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.

-
- {% highlight js %} +
+ {% highlight js %} { - "circle-radius": { - "stops": [ - - // zoom is 5 -> circle radius will be 1px - [5, 1], - - // zoom is 10 -> circle radius will be 2px - [10, 2] - + "circle-color": { + "expression": [ + 'rgb', + // red is higher when feature.properties.temperature is higher + ["get", "temperature"], + 0, + // blue is higher when feature.properties.temperature is lower + ["-", 100, ["get", "temperature"]] ] } } - {% endhighlight %} -
+{% endhighlight %} +
-

The rendered values of color, number, and array properties are intepolated between stops. Enum, boolean, and string property values cannot be intepolated, so their rendered values only change at the specified stops.

+A zoom function is any function defined using an expression that includes a reference to ["zoom"]. Such functions allow the appearance of a map feature change with map’s zoom level. Zoom functions can be used to create the illusion of depth and control data density. -

There is an important difference between the way that zoom functions render for layout and paint properties. Paint properties are continuously re-evaluated whenever the zoom level changes, even fractionally. The rendered value of a paint property will change, for example, as the map moves between zoom levels 4.1 and 4.6. Layout properties, on the other hand, are evaluated only once for each integer zoom level. To continue the prior example: the rendering of a layout property will not change between zoom levels 4.1 and 4.6, no matter what stops are specified; but at zoom level 5, the function will be re-evaluated according to the function, and the property's rendered value will change. (You can include fractional zoom levels in a layout property zoom function, and it will affect the generated values; but, still, the rendering will only change at integer zoom levels.)

+ A zoom function must be of the following form (see curves for more details): -

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. 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.

+
+{% highlight js %} +{ + "expression": [ "curve", interpolation, ["zoom"], ... ] +} +{% endhighlight %} +
+ + Or: -
- {% highlight js %} +
+{% highlight js %} { - "circle-color": { - "property": "temperature", - "stops": [ + "expression": [ "coalesce", [ "curve", interpolation, ["zoom"], ... ], ... ] +} +{% endhighlight %} +
+ +

(["zoom"] may not appear anywhere else in the expression.)

- // "temperature" is 0 -> circle color will be blue - [0, 'blue'], - // "temperature" is 100 -> circle color will be red - [100, 'red'] +

Example: a zoom-only function

+
+{% highlight js %} +{ + "circle-radius": { + "expression": [ + "curve", "linear", ["zoom"], + // zoom is 5 (or less) -> circle radius will be 1px + // zoom is 10 (or greater) -> circle radius will be 2px + 5, 1, 10, 2 ] } } - {% endhighlight %} -
+{% endhighlight %} +
-

Zoom-and-property functions allow the appearance of a map feature to change with both its properties and zoom. Each stop is an array with two elements, the first is an object with a property input value and a zoom, and the second is a function output value. Note that support for property functions is not yet complete.

+

Example: a zoom-and-property function

-
- {% highlight js %} +

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.

+ +
+{% highlight js %} { "circle-radius": { - "property": "rating", - "stops": [ + "expression": [ + "curve", "linear", ["zoom"], // zoom is 0 and "rating" is 0 -> circle radius will be 0px - [{zoom: 0, value: 0}, 0], - // zoom is 0 and "rating" is 5 -> circle radius will be 5px - [{zoom: 0, value: 5}, 5], - - // zoom is 20 and "rating" is 0 -> circle radius will be 0px - [{zoom: 20, value: 0}, 0], - - // zoom is 20 and "rating" is 5 -> circle radius will be 20px - [{zoom: 20, value: 5}, 20] - + 0, ["number", [ "get", "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 + 10, [ "*", 4, [ "number", [ "get", ["properties"] ] ] ] ] } } - {% endhighlight %} -
+{% endhighlight %} +
+ +

There is an important difference between the way that zoom functions render for layout and paint properties. Paint properties are continuously re-evaluated whenever the zoom level changes, even fractionally. The rendered value of a paint property will change, for example, as the map moves between zoom levels 4.1 and 4.6. Layout properties, on the other hand, are evaluated only once for each integer zoom level. To continue the prior example: the rendering of a layout property will not change between zoom levels 4.1 and 4.6, no matter what stops are specified; but at zoom level 5, the function will be re-evaluated according to the function, and the property's rendered value will change. (You can include fractional zoom levels in a layout property zoom function, and it will affect the generated values; but, still, the rendering will only change at integer zoom levels.)

+
+ +

Expression

+ <% for (var name of expressions) { %> + <%= renderExpression({name: name}) %> + <% } %> +
+

Filter

diff --git a/docs/style-spec/expressions.json b/docs/style-spec/expressions.json new file mode 100644 index 00000000000..1a67ea01dc5 --- /dev/null +++ b/docs/style-spec/expressions.json @@ -0,0 +1,7 @@ +[ + { + "name": "curve", + "group": "curve", + "doc": "Curve" + } +] diff --git a/docs/style-spec/expressions.md b/docs/style-spec/expressions.md new file mode 100644 index 00000000000..5c002bc3b4e --- /dev/null +++ b/docs/style-spec/expressions.md @@ -0,0 +1,197 @@ +**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.** + +# Style 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. + +`expression` +_Required [expression value](#Expressions)_ +An expression defines how one or more feature property values and/or the current zoom level are combined using logical, mathematical, string, or color operations to produce the appropriate style value. See [Expressions](#Expressions) for syntax details. + +## Property functions + +

A property function is any function defined using an expression that includes a reference to `["properties"]`. Property functions allow the appearance of a map feature to change with its properties. They 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.

+ +```js +{ + "circle-color": { + "expression": [ + 'rgb', + // red is higher when feature.properties.temperature is higher + ["number", ["get", "temperature"]], + 0, + // blue is higher when feature.properties.temperature is lower + ["-", 100, ["number", ["get", "temperature"] + ] + } +} +``` + + +## Zoom-dependent functions + +A zoom function is any function defined using an expression that includes a reference to `["zoom"]`. Such functions allow the appearance of a map feature change with map’s zoom level. Zoom functions can be used to create the illusion of depth and control data density. + +A zoom function must be of the following form (see [Curves](#Curves) below): + +```js +{ + "expression": [ "curve", interpolation, ["zoom"], ... ] +} +``` + +Or: + +```js +{ + "expression": [ "coalesce", [ "curve", interpolation, ["zoom"], ... ], ... ] +} +``` + +(`["zoom"]` may not appear anywhere else in the expression.) + + +### Example: a zoom-only function. + +```js +{ + "circle-radius": { + "expression": [ + "curve", "linear", ["zoom"], + // zoom is 5 (or less) -> circle radius will be 1px + // zoom is 10 (or greater) -> circle radius will be 2px + 5, 1, 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": { + "expression": [ + "curve", "linear", ["zoom"], + + // 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, ["number", [ "get", "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 + 10, [ "*", 4, [ "number", [ "get", ["properties"] ] ] ] + ] + } +} +``` + +## Property Expressions + +Property expressions are represented using a Lisp-like structured syntax tree. + +### Types + +Every expression evaluates to a value of one of the following types. + +- `Null` + - Literal: `null` +- `String` + - Literal: `"my string value"` +- `Number` + - Literal: `1729` +- `Boolean` + - Literal: `true` or `false` +- `Color` +- `Object` + - Literal: `["literal", { "key": value, "key2": value2 }]` +- `Array`: an array of N values of type T + - Literal: `["literal", [v0, v1, ...]]` +- `Array`: a dynamically sized array of values of type `T` +- `Array`: equivalent to `Array` +- `Value`: A "variant" type representing the set of possible values retrievable from a feature's `properties` object (`Null | String | Number | Boolean | Object | Array`) +- `Error`: a subtype of all other types. Used wherever an expression is unable to return the appropriate supertype. Carries diagnostic information such as an explanatory message and the expression location where the Error value was generated. + +### Constants: +- `[ "ln2" ] -> Number` +- `[ "pi" ] -> Number` +- `[ "e" ] -> Number` + +### Variable binding: +- `[ "let", name1: String, e1, name2: String, e2, ..., e_result ]` - Bind expression `e1` to the string `name1`, `e2` to `name2`, etc., before evaluating `e_result`. The bound expressions may be referenced within `e_result` with `[ "var", name1 ]`, `[ "var", name2 ]`, etc. (E.g.: `["let", "a", 1, "b", ["number", ["get", "blah"]], [ "+", ["var", "a"], ["var", "b"] ]`.) + +### Type assertion: +Assert that the argument is of a specific type, producing a runtime error if it is not. + +- `["string", e: Value] -> String` +- `["number", e:Value] -> Number` +- `["boolean", e:Value] -> Boolean` +- `["object", e:Value] -> Object` +- `["array", e:Value] -> Array` +- `["array", T, length, e: Value] -> Array` + +### Type conversion: +Convert the argument to the given type, producing a runtime error if the conversion is not possible. + +- `["to-string", e: Value] -> String` +- `["to-number", e:Value] -> Number` + - Uses platform-default string-to-number conversion. (TBD: parse locale-specificformatted number strings) +- `["to-boolean", e:Value] -> Boolean` + - `0`, `''`, and `null` are considered falsy. + +### Lookup: +- `["get", key: String, obj: Object = ["properties"] ] -> Value` - note that the final argument defaults to `["properties"]`, so to get feature property `"x"`, simply use `["get", "x"]`. +- `["has", key: String, obj: Object = ["properties"] ] -> Boolean` +- `["at", index: Number, arr: Array|Array] -> T` +- `["typeof", expr: Value] -> String` +- `["length", e: Array|String] -> Number` +- `["contains", value: T, arr: Array|Array] -> Boolean` + +### Feature data: +- `["properties"] -> Object` the feature's `properties` object +- `["geometry-type"] -> String` the string value of `feature.geometry.type` +- `[ "id" ] -> Value` returns the value of `feature.id`. + +### Decision: +- `["case", cond1: Boolean, result1: T, cond2: Boolean, result2: T, ..., cond_m, result_m: T, result_otherwise: T] -> T` +- `["match", x: T, a_1: T, y_1: U, a_2: T, y_2: U, ..., a_m: T, y_m: U, y_else: U]` - `a_1`, `a_2`, ... must be _literal_ values of type `T`. +- `["coalesce", e1: T, e2: T, e3: T, ...] -> T` - evaluates each expression in turn until the first non-null, non-error value is obtained, and returns that value. + +### Comparison and boolean operations: +- `[ "==", expr1: T, expr2: T] -> Boolean`, where T is any primitive type. (similar for `!=`) +- `[ ">", lhs_expr: T, rhs_expr: T ] -> Boolean`, where T is any primitive type. (similar for <, >=, <=) +- `[ "&&", e1: Boolean, e2: Boolean, ... ] -> Boolean` (similar for `||`) +- `[ "!", e: Boolean] -> Boolean` + +### Curves: + +`["curve", interpolation, x: Number, n_1: Number, y_1: T, ..., n_m: Number, y_m: T] -> T` defines a function with `(n_1, y_1)`, ..., `(n_m, y_m)` as input/output pairs, and `interpolation` dictating how inputs between `n_i` and `n_(i+1)` are computed. +- The `n_i`'s must be numeric literals in strictly ascending order (`n_1 < n_2 < n_3 < ...`) +- Specific `interpolation` types may imply certain restrictions on the output type `T`. +- `interpolation` is one of: + * `["step"]` - equivalent to existing "interval" function behavior. + * `["exponential", base]` - `base` is a number > 0; equivalent to existing "exponential" function behavior. `T` must be `Number` or `Color` + * `["linear"]` - equivalent to `["exponential", 1]` + * `["cubic-bezier", x1, y1, x2, y2]` - define your own interpolation. `T` must be `Number` **(not yet implemented)** + +### Math: +All of the following take `Number` inputs and produce a `Number`. +- +, -, \*, /, %, ^ (e.g. `["+", expr1, expr2, expr3, …]`, `["-", expr1, expr2 ]`, etc.) +- log10, ln, log2 +- sin, cos, tan, asin, acos, atan +- ceil, floor, round, abs +- min, max + +### String: +- `["concat", expr1: T, expr2: U, …] -> String` +- `["upcase", e: String] -> String`, `["downcase", e: String] -> String` + +### Color: +- `['rgb', r: Number, g: Number, b: Number] -> Color` equivalent to `['rgba', r, g, b, 1]` +- `['rgba', r: Number, g: Number, b: Number, a: Number] -> Color` +- `["color", c: String] -> Color` + - `c` may be a CSS color name (`green`) or hex-encoded color string (`#4455FF`) + diff --git a/flow-typed/style-spec.js b/flow-typed/style-spec.js index 1c3543c78bd..73b8f31950e 100644 --- a/flow-typed/style-spec.js +++ b/flow-typed/style-spec.js @@ -21,29 +21,35 @@ declare type TransitionSpecification = { // Note: doesn't capture interpolatable vs. non-interpolatable types. declare type CameraFunctionSpecification = - | { type: 'exponential', stops: Array<[number, T]> } - | { type: 'interval', stops: Array<[number, T]> }; + | {| type: 'exponential', stops: Array<[number, T]> |} + | {| type: 'interval', stops: Array<[number, T]> |}; declare type SourceFunctionSpecification = - | { type: 'exponential', stops: Array<[number, T]>, property: string, default?: T } - | { type: 'interval', stops: Array<[number, T]>, property: string, default?: T } - | { type: 'categorical', stops: Array<[string | number | boolean, T]>, property: string, default?: T } - | { type: 'identity', property: string, default?: T }; + | {| type: 'exponential', stops: Array<[number, T]>, property: string, default?: T |} + | {| type: 'interval', stops: Array<[number, T]>, property: string, default?: T |} + | {| type: 'categorical', stops: Array<[string | number | boolean, T]>, property: string, default?: T |} + | {| type: 'identity', property: string, default?: T |}; declare type CompositeFunctionSpecification = - | { type: 'exponential', stops: Array<[{zoom: number, value: number}, T]>, property: string, default?: T } - | { type: 'interval', stops: Array<[{zoom: number, value: number}, T]>, property: string, default?: T } - | { type: 'categorical', stops: Array<[{zoom: number, value: string | number | boolean}, T]>, property: string, default?: T }; + | {| type: 'exponential', stops: Array<[{zoom: number, value: number}, T]>, property: string, default?: T |} + | {| type: 'interval', stops: Array<[{zoom: number, value: number}, T]>, property: string, default?: T |} + | {| type: 'categorical', stops: Array<[{zoom: number, value: string | number | boolean}, T]>, property: string, default?: T |}; + +declare type ExpressionFunctionSpecification = {| + expression: mixed +|} declare type PropertyValueSpecification = | T - | CameraFunctionSpecification; + | CameraFunctionSpecification + | ExpressionFunctionSpecification; declare type DataDrivenPropertyValueSpecification = | T | CameraFunctionSpecification | SourceFunctionSpecification - | CompositeFunctionSpecification; + | CompositeFunctionSpecification + | ExpressionFunctionSpecification; declare type StyleSpecification = {| "version": 8, diff --git a/flow-typed/visitor.js b/flow-typed/visitor.js new file mode 100644 index 00000000000..6f278ab7a76 --- /dev/null +++ b/flow-typed/visitor.js @@ -0,0 +1,3 @@ +declare type Visitor = { + visit: (T) => void +}; diff --git a/package.json b/package.json index ad7924460a0..2f7c7668bd3 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "highlight.js": "^9.9.0", "in-publish": "^2.0.0", "jsdom": "^9.11.0", + "json-stringify-pretty-compact": "^1.0.4", "lodash": "^4.16.0", "minifyify": "^7.0.1", "mock-geolocation": "^1.0.11", @@ -133,13 +134,14 @@ "open-changed-examples": "git diff --name-only mb-pages HEAD -- docs/_posts/examples/*.html | awk '{print \"http://127.0.0.1:4000/mapbox-gl-js/example/\" substr($0,33,length($0)-37)}' | xargs open", "test": "run-s lint lint-css test-unit test-flow", "test-suite": "run-s test-render test-query", - "test-suite-clean": "find test/integration/*-tests -mindepth 2 -type d -not \\( -exec test -e \"{}/style.json\" \\; \\) -print | xargs -t rm -r", + "test-suite-clean": "find test/integration/{render,query}-tests -mindepth 2 -type d -not \\( -exec test -e \"{}/style.json\" \\; \\) -print | xargs -t rm -r", "test-unit": "tap --reporter dot --no-coverage test/unit", "test-render": "node --max-old-space-size=2048 test/render.test.js", "test-query": "node test/query.test.js", + "test-expressions": "node test/expression.test.js", "test-flow": "flow .", "test-flow-cov": "flow-coverage-report -i 'src/**/*.js' -t html", - "test-cov": "nyc --require=flow-remove-types/register --reporter=text-summary --reporter=lcov --cache run-s test-unit test-render test-query", + "test-cov": "nyc --require=flow-remove-types/register --reporter=text-summary --reporter=lcov --cache run-s test-unit test-expressions test-query test-render", "prepublish": "in-publish && run-s build-dev build-min || not-in-publish" }, "files": [ diff --git a/src/data/bucket/circle_bucket.js b/src/data/bucket/circle_bucket.js index 8716168f317..67ff9b8413a 100644 --- a/src/data/bucket/circle_bucket.js +++ b/src/data/bucket/circle_bucket.js @@ -148,7 +148,7 @@ class CircleBucket implements Bucket { } } - this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature.properties); + this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature); } } diff --git a/src/data/bucket/fill_bucket.js b/src/data/bucket/fill_bucket.js index 5dd184bfeaf..156601386a9 100644 --- a/src/data/bucket/fill_bucket.js +++ b/src/data/bucket/fill_bucket.js @@ -167,7 +167,7 @@ class FillBucket implements Bucket { triangleSegment.primitiveLength += indices.length / 3; } - this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature.properties); + this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature); } } diff --git a/src/data/bucket/fill_extrusion_bucket.js b/src/data/bucket/fill_extrusion_bucket.js index 51a158dca59..ed9e2bb3e68 100644 --- a/src/data/bucket/fill_extrusion_bucket.js +++ b/src/data/bucket/fill_extrusion_bucket.js @@ -193,7 +193,7 @@ class FillExtrusionBucket implements Bucket { segment.primitiveLength += triangleIndices.length / 3; } - this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature.properties); + this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature); } } diff --git a/src/data/bucket/line_bucket.js b/src/data/bucket/line_bucket.js index e8f8b4206b6..1a9da22e5bc 100644 --- a/src/data/bucket/line_bucket.js +++ b/src/data/bucket/line_bucket.js @@ -167,7 +167,7 @@ class LineBucket implements Bucket { addFeature(feature: VectorTileFeature) { const layout = this.layers[0].layout; - const join = this.layers[0].getLayoutValue('line-join', {zoom: this.zoom}, feature.properties); + const join = this.layers[0].getLayoutValue('line-join', {zoom: this.zoom}, feature); const cap = layout['line-cap']; const miterLimit = layout['line-miter-limit']; const roundLimit = layout['line-round-limit']; @@ -178,7 +178,6 @@ class LineBucket implements Bucket { } addLine(vertices: Array, feature: VectorTileFeature, join: string, cap: string, miterLimit: number, roundLimit: number) { - const featureProperties = feature.properties; const isPolygon = vectorTileFeatureTypes[feature.type] === 'Polygon'; // If the line has duplicate vertices at the ends, adjust start/length to remove them. @@ -441,7 +440,7 @@ class LineBucket implements Bucket { startOfLine = false; } - this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, featureProperties); + this.programConfigurations.populatePaintArrays(this.layoutVertexArray.length, feature); } /** diff --git a/src/data/bucket/symbol_bucket.js b/src/data/bucket/symbol_bucket.js index 72c15b48dd9..3230e8a203d 100644 --- a/src/data/bucket/symbol_bucket.js +++ b/src/data/bucket/symbol_bucket.js @@ -27,6 +27,7 @@ const vectorTileFeatureTypes = require('@mapbox/vector-tile').VectorTileFeature. const createStructArrayType = require('../../util/struct_array'); const verticalizePunctuation = require('../../util/verticalize_punctuation'); +import type {Feature as ExpressionFeature} from '../../style-spec/function'; import type {Bucket, BucketParameters, IndexedFeature, PopulateParameters} from '../bucket'; import type {ProgramInterface, SerializedProgramConfiguration} from '../program_configuration'; import type CollisionBoxArray, {CollisionBox} from '../../symbol/collision_box'; @@ -38,6 +39,7 @@ import type { import type StyleLayer from '../../style/style_layer'; import type {Shaping, PositionedIcon} from '../../symbol/shaping'; import type {SymbolQuad} from '../../symbol/quads'; +import type {SizeData} from '../../symbol/symbol_size'; type SymbolBucketParameters = BucketParameters & { sdfIcons: boolean, @@ -63,21 +65,22 @@ type SymbolInstance = { anchor: Anchor, line: Array, featureIndex: number, - featureProperties: Object, + feature: ExpressionFeature, writingModes: number, textCollisionFeature?: {boxStartIndex: number, boxEndIndex: number}, iconCollisionFeature?: {boxStartIndex: number, boxEndIndex: number} }; -export type SymbolFeature = { +export type SymbolFeature = {| text: string | void, icon: string | void, index: number, sourceLayerIndex: number, geometry: Array>, properties: Object, - type: 'Point' | 'LineString' | 'Polygon' -}; + type: 'Point' | 'LineString' | 'Polygon', + id?: any +|}; type ShapedTextOrientations = { '1'?: Shaping, @@ -369,7 +372,7 @@ class SymbolBucket implements Bucket { } populate(features: Array, options: PopulateParameters) { - const layer = this.layers[0]; + const layer: StyleLayer = this.layers[0]; const layout = layer.layout; const textFont = layout['text-font']; @@ -394,16 +397,16 @@ class SymbolBucket implements Bucket { let text; if (hasText) { - text = layer.getLayoutValue('text-field', globalProperties, feature.properties); + text = layer.getLayoutValue('text-field', globalProperties, feature); if (layer.isLayoutValueFeatureConstant('text-field')) { text = resolveTokens(feature.properties, text); } - text = transformText(text, layer, globalProperties, feature.properties); + text = transformText(text, layer, globalProperties, feature); } let icon; if (hasIcon) { - icon = layer.getLayoutValue('icon-image', globalProperties, feature.properties); + icon = layer.getLayoutValue('icon-image', globalProperties, feature); if (layer.isLayoutValueFeatureConstant('icon-image')) { icon = resolveTokens(feature.properties, icon); } @@ -413,7 +416,7 @@ class SymbolBucket implements Bucket { continue; } - this.features.push({ + const symbolFeature: SymbolFeature = { text, icon, index, @@ -421,7 +424,11 @@ class SymbolBucket implements Bucket { geometry: loadGeometry(feature), properties: feature.properties, type: vectorTileFeatureTypes[feature.type] - }); + }; + if (typeof feature.id !== 'undefined') { + symbolFeature.id = feature.id; + } + this.features.push(symbolFeature); if (icon) { icons[icon] = true; @@ -507,13 +514,13 @@ class SymbolBucket implements Bucket { const text = feature.text; if (text) { const allowsVerticalWritingMode = scriptDetection.allowsVerticalWritingMode(text); - const textOffset = this.layers[0].getLayoutValue('text-offset', {zoom: this.zoom}, feature.properties).map((t)=> t * oneEm); - const spacing = this.layers[0].getLayoutValue('text-letter-spacing', {zoom: this.zoom}, feature.properties) * oneEm; + const textOffset = this.layers[0].getLayoutValue('text-offset', {zoom: this.zoom}, feature).map((t)=> t * oneEm); + const spacing = this.layers[0].getLayoutValue('text-letter-spacing', {zoom: this.zoom}, feature) * oneEm; const spacingIfAllowed = scriptDetection.allowsLetterSpacing(text) ? spacing : 0; - const textAnchor = this.layers[0].getLayoutValue('text-anchor', {zoom: this.zoom}, feature.properties); - const textJustify = this.layers[0].getLayoutValue('text-justify', {zoom: this.zoom}, feature.properties); + const textAnchor = this.layers[0].getLayoutValue('text-anchor', {zoom: this.zoom}, feature); + const textJustify = this.layers[0].getLayoutValue('text-justify', {zoom: this.zoom}, feature); const maxWidth = layout['symbol-placement'] !== 'line' ? - this.layers[0].getLayoutValue('text-max-width', {zoom: this.zoom}, feature.properties) * oneEm : + this.layers[0].getLayoutValue('text-max-width', {zoom: this.zoom}, feature) * oneEm : 0; shapedTextOrientations = { @@ -547,8 +554,8 @@ class SymbolBucket implements Bucket { const image = icons[feature.icon]; if (image) { shapedIcon = shapeIcon(image, - this.layers[0].getLayoutValue('icon-offset', {zoom: this.zoom}, feature.properties), - this.layers[0].getLayoutValue('icon-anchor', {zoom: this.zoom}, feature.properties)); + this.layers[0].getLayoutValue('icon-offset', {zoom: this.zoom}, feature), + this.layers[0].getLayoutValue('icon-anchor', {zoom: this.zoom}, feature)); if (this.sdfIcons === undefined) { this.sdfIcons = image.sdf; } else if (this.sdfIcons !== image.sdf) { @@ -577,17 +584,17 @@ class SymbolBucket implements Bucket { * @private */ addFeature(feature: SymbolFeature, shapedTextOrientations: ShapedTextOrientations, shapedIcon: PositionedIcon | void) { - const layoutTextSize = this.layers[0].getLayoutValue('text-size', {zoom: this.zoom + 1}, feature.properties); - const layoutIconSize = this.layers[0].getLayoutValue('icon-size', {zoom: this.zoom + 1}, feature.properties); + const layoutTextSize = this.layers[0].getLayoutValue('text-size', {zoom: this.zoom + 1}, feature); + const layoutIconSize = this.layers[0].getLayoutValue('icon-size', {zoom: this.zoom + 1}, feature); - const textOffset = this.layers[0].getLayoutValue('text-offset', {zoom: this.zoom }, feature.properties); - const iconOffset = this.layers[0].getLayoutValue('icon-offset', {zoom: this.zoom }, feature.properties); + const textOffset = this.layers[0].getLayoutValue('text-offset', {zoom: this.zoom }, feature); + const iconOffset = this.layers[0].getLayoutValue('icon-offset', {zoom: this.zoom }, feature); // To reduce the number of labels that jump around when zooming we need // to use a text-size value that is the same for all zoom levels. // This calculates text-size at a high zoom level so that all tiles can // use the same value when calculating anchor positions. - let textMaxSize = this.layers[0].getLayoutValue('text-size', {zoom: 18}, feature.properties); + let textMaxSize = this.layers[0].getLayoutValue('text-size', {zoom: 18}, feature); if (textMaxSize === undefined) { textMaxSize = layoutTextSize; } @@ -629,7 +636,7 @@ class SymbolBucket implements Bucket { addToBuffers, this.collisionBoxArray, feature.index, feature.sourceLayerIndex, this.index, textBoxScale, textPadding, textAlongLine, textOffset, iconBoxScale, iconPadding, iconAlongLine, iconOffset, - {zoom: this.zoom}, feature.properties); + {zoom: this.zoom}, feature); }; if (symbolPlacement === 'line') { @@ -791,9 +798,9 @@ class SymbolBucket implements Bucket { if (glyphScale <= maxScale) { const textSizeData = getSizeVertexData(layer, this.zoom, - this.textSizeData.coveringZoomRange, + this.textSizeData, 'text-size', - symbolInstance.featureProperties); + symbolInstance.feature); this.addSymbols( this.text, symbolInstance.glyphQuads, @@ -803,7 +810,7 @@ class SymbolBucket implements Bucket { symbolInstance.textOffset, textAlongLine, collisionTile.angle, - symbolInstance.featureProperties, + symbolInstance.feature, symbolInstance.writingModes, symbolInstance.anchor, lineStartIndex, @@ -818,9 +825,9 @@ class SymbolBucket implements Bucket { const iconSizeData = getSizeVertexData( layer, this.zoom, - this.iconSizeData.coveringZoomRange, + this.iconSizeData, 'icon-size', - symbolInstance.featureProperties); + symbolInstance.feature); this.addSymbols( this.icon, symbolInstance.iconQuads, @@ -830,7 +837,7 @@ class SymbolBucket implements Bucket { symbolInstance.iconOffset, iconAlongLine, collisionTile.angle, - symbolInstance.featureProperties, + symbolInstance.feature, 0, symbolInstance.anchor, lineStartIndex, @@ -853,7 +860,7 @@ class SymbolBucket implements Bucket { lineOffset: [number, number], alongLine: boolean, placementAngle: number, - featureProperties: Object, + feature: ExpressionFeature, writingModes: number, labelAnchor: Anchor, lineStartIndex: number, @@ -914,7 +921,7 @@ class SymbolBucket implements Bucket { lineOffset[0], lineOffset[1], placementZoom, useVerticalMode); - arrays.programConfigurations.populatePaintArrays(arrays.layoutVertexArray.length, featureProperties); + arrays.programConfigurations.populatePaintArrays(arrays.layoutVertexArray.length, feature); } addToDebugBuffers(collisionTile: CollisionTile) { @@ -1005,7 +1012,7 @@ class SymbolBucket implements Bucket { iconAlongLine: boolean, iconOffset: [number, number], globalProperties: Object, - featureProperties: Object) { + feature: SymbolFeature) { let textCollisionFeature, iconCollisionFeature; let iconQuads = []; @@ -1015,7 +1022,7 @@ class SymbolBucket implements Bucket { if (!shapedTextOrientations[writingMode]) continue; glyphQuads = glyphQuads.concat(addToBuffers ? getGlyphQuads(anchor, shapedTextOrientations[writingMode], - layer, textAlongLine, globalProperties, featureProperties) : + layer, textAlongLine, globalProperties, feature) : []); textCollisionFeature = new CollisionFeature(collisionBoxArray, line, @@ -1037,7 +1044,7 @@ class SymbolBucket implements Bucket { iconQuads = addToBuffers ? getIconQuads(anchor, shapedIcon, layer, iconAlongLine, shapedTextOrientations[WritingMode.horizontal], - globalProperties, featureProperties) : + globalProperties, feature) : []; iconCollisionFeature = new CollisionFeature(collisionBoxArray, line, @@ -1079,7 +1086,7 @@ class SymbolBucket implements Bucket { anchor, line, featureIndex, - featureProperties, + feature, writingModes }); } @@ -1087,69 +1094,64 @@ class SymbolBucket implements Bucket { // For {text,icon}-size, get the bucket-level data that will be needed by // the painter to set symbol-size-related uniforms -function getSizeData(tileZoom, layer, sizeProperty) { - const sizeData = {}; +function getSizeData(tileZoom: number, layer: StyleLayer, sizeProperty: string): SizeData { + const isFeatureConstant = layer.isLayoutValueFeatureConstant(sizeProperty); + const isZoomConstant = layer.isLayoutValueZoomConstant(sizeProperty); - sizeData.isFeatureConstant = layer.isLayoutValueFeatureConstant(sizeProperty); - sizeData.isZoomConstant = layer.isLayoutValueZoomConstant(sizeProperty); + if (isZoomConstant && !isFeatureConstant) { + return { functionType: 'source' }; + } - if (sizeData.isFeatureConstant) { - sizeData.layoutSize = layer.getLayoutValue(sizeProperty, {zoom: tileZoom + 1}); + if (isZoomConstant && isFeatureConstant) { + return { + functionType: 'constant', + layoutSize: layer.getLayoutValue(sizeProperty, {zoom: tileZoom + 1}) + }; } // calculate covering zoom stops for zoom-dependent values - if (!sizeData.isZoomConstant) { - const levels = layer.getLayoutValueStopZoomLevels(sizeProperty); - let lower = 0; - while (lower < levels.length && levels[lower] <= tileZoom) lower++; - lower = Math.max(0, lower - 1); - let upper = lower; - while (upper < levels.length && levels[upper] < tileZoom + 1) upper++; - upper = Math.min(levels.length - 1, upper); - - sizeData.coveringZoomRange = [levels[lower], levels[upper]]; - if (layer.isLayoutValueFeatureConstant(sizeProperty)) { - // for camera functions, also save off the function values - // evaluated at the covering zoom levels - sizeData.coveringStopValues = [ + const levels = layer.getLayoutValueStopZoomLevels(sizeProperty); + let lower = 0; + while (lower < levels.length && levels[lower] <= tileZoom) lower++; + lower = Math.max(0, lower - 1); + let upper = lower; + while (upper < levels.length && levels[upper] < tileZoom + 1) upper++; + upper = Math.min(levels.length - 1, upper); + + const coveringZoomRange: [number, number] = [levels[lower], levels[upper]]; + + if (!isFeatureConstant) { + return { + functionType: 'composite', + coveringZoomRange + }; + } else { + // for camera functions, also save off the function values + // evaluated at the covering zoom levels + return { + functionType: 'camera', + layoutSize: layer.getLayoutValue(sizeProperty, {zoom: tileZoom + 1}), + coveringZoomRange, + coveringStopValues: [ layer.getLayoutValue(sizeProperty, {zoom: levels[lower]}), layer.getLayoutValue(sizeProperty, {zoom: levels[upper]}) - ]; - } - - // also store the function's base for use in calculating the - // interpolation factor each frame - sizeData.functionBase = layer.getLayoutProperty(sizeProperty).base; - if (typeof sizeData.functionBase === 'undefined') { - sizeData.functionBase = 1; - } - sizeData.functionType = layer.getLayoutProperty(sizeProperty).type || - 'exponential'; + ] + }; } - - return sizeData; } -function getSizeVertexData(layer, tileZoom, stopZoomLevels, sizeProperty, featureProperties) { - if ( - layer.isLayoutValueZoomConstant(sizeProperty) && - !layer.isLayoutValueFeatureConstant(sizeProperty) - ) { - // source function +function getSizeVertexData(layer: StyleLayer, tileZoom: number, sizeData: SizeData, sizeProperty, feature) { + if (sizeData.functionType === 'source') { return [ - 10 * layer.getLayoutValue(sizeProperty, ({}: any), featureProperties) + 10 * layer.getLayoutValue(sizeProperty, ({}: any), feature) ]; - } else if ( - !layer.isLayoutValueZoomConstant(sizeProperty) && - !layer.isLayoutValueFeatureConstant(sizeProperty) - ) { - // composite function + } else if (sizeData.functionType === 'composite') { + const zoomRange = sizeData.coveringZoomRange; return [ - 10 * layer.getLayoutValue(sizeProperty, {zoom: stopZoomLevels[0]}, featureProperties), - 10 * layer.getLayoutValue(sizeProperty, {zoom: stopZoomLevels[1]}, featureProperties) + 10 * layer.getLayoutValue(sizeProperty, {zoom: zoomRange[0]}, feature), + 10 * layer.getLayoutValue(sizeProperty, {zoom: zoomRange[1]}, feature) ]; } - // camera function or constant return null; } diff --git a/src/data/program_configuration.js b/src/data/program_configuration.js index 18eaf222232..5b81acaa0bc 100644 --- a/src/data/program_configuration.js +++ b/src/data/program_configuration.js @@ -1,13 +1,13 @@ // @flow const createVertexArrayType = require('./vertex_array_type'); -const interpolationFactor = require('../style-spec/function').interpolationFactor; const packUint8ToFloat = require('../shaders/encode_attribute').packUint8ToFloat; const VertexBuffer = require('../gl/vertex_buffer'); import type StyleLayer from '../style/style_layer'; import type {ViewType, StructArray, SerializedStructArray, StructArrayTypeParameters} from '../util/struct_array'; import type Program from '../render/program'; +import type {Feature} from '../style-spec/function'; type LayoutAttribute = { name: string, @@ -48,7 +48,7 @@ interface Binder { statistics: PaintPropertyStatistics, start: number, length: number, - featureProperties: Object): void; + feature: Feature): void; defines(): Array; @@ -107,8 +107,8 @@ class SourceFunctionBinder implements Binder { statistics: PaintPropertyStatistics, start: number, length: number, - featureProperties: Object) { - const value = layer.getPaintValue(this.property, undefined, featureProperties); + feature: Feature) { + const value = layer.getPaintValue(this.property, undefined, feature); if (this.type === 'color') { const color = packColor(value); @@ -157,9 +157,9 @@ class CompositeFunctionBinder implements Binder { statistics: PaintPropertyStatistics, start: number, length: number, - featureProperties: Object) { - const min = layer.getPaintValue(this.property, {zoom: this.zoom }, featureProperties); - const max = layer.getPaintValue(this.property, {zoom: this.zoom + 1}, featureProperties); + feature: Feature) { + const min = layer.getPaintValue(this.property, {zoom: this.zoom }, feature); + const max = layer.getPaintValue(this.property, {zoom: this.zoom + 1}, feature); if (this.type === 'color') { const minColor = packColor(min); @@ -184,7 +184,7 @@ class CompositeFunctionBinder implements Binder { } setUniforms(gl: WebGLRenderingContext, program: Program, layer: StyleLayer, {zoom}: { zoom: number }) { - const f = interpolationFactor(this.useIntegerZoom ? Math.floor(zoom) : zoom, 1, this.zoom, this.zoom + 1); + const f = layer.getPaintInterpolationFactor(this.property, this.useIntegerZoom ? Math.floor(zoom) : zoom, this.zoom, this.zoom + 1); gl.uniform1f(program.uniforms[`a_${this.name}_t`], f); } } @@ -295,7 +295,7 @@ class ProgramConfiguration { return paintPropertyStatistics; } - populatePaintArray(length: number, featureProperties: Object) { + populatePaintArray(length: number, feature: Feature) { const paintArray = this.paintVertexArray; if (paintArray.bytesPerElement === 0) return; @@ -307,7 +307,7 @@ class ProgramConfiguration { this.layer, paintArray, this.paintPropertyStatistics, start, length, - featureProperties); + feature); } } @@ -378,9 +378,9 @@ class ProgramConfigurationSet { } } - populatePaintArrays(length: number, featureProperties: Object) { + populatePaintArrays(length: number, feature: Feature) { for (const key in this.programConfigurations) { - this.programConfigurations[key].populatePaintArray(length, featureProperties); + this.programConfigurations[key].populatePaintArray(length, feature); } } diff --git a/src/render/draw_symbol.js b/src/render/draw_symbol.js index f24a459409e..240f218c1cb 100644 --- a/src/render/draw_symbol.js +++ b/src/render/draw_symbol.js @@ -169,8 +169,10 @@ function setSymbolDrawState(program, painter, layer, tileZoom, isText, isSDF, ro gl.uniform1f(program.uniforms.u_pitch, tr.pitch / 360 * 2 * Math.PI); - gl.uniform1i(program.uniforms.u_is_size_zoom_constant, sizeData.isZoomConstant ? 1 : 0); - gl.uniform1i(program.uniforms.u_is_size_feature_constant, sizeData.isFeatureConstant ? 1 : 0); + const isZoomConstant = sizeData.functionType === 'constant' || sizeData.functionType === 'source'; + const isFeatureConstant = sizeData.functionType === 'constant' || sizeData.functionType === 'camera'; + gl.uniform1i(program.uniforms.u_is_size_zoom_constant, isZoomConstant ? 1 : 0); + gl.uniform1i(program.uniforms.u_is_size_feature_constant, isFeatureConstant ? 1 : 0); gl.uniform1f(program.uniforms.u_camera_to_center_distance, tr.cameraToCenterDistance); diff --git a/src/style-spec/function/check_subtype.js b/src/style-spec/function/check_subtype.js new file mode 100644 index 00000000000..d95082a01a7 --- /dev/null +++ b/src/style-spec/function/check_subtype.js @@ -0,0 +1,81 @@ +// @flow + +const { + NullType, + NumberType, + StringType, + BooleanType, + ObjectType, + ColorType, + ValueType, + array, + toString +} = require('./types'); + +import type {ParsingContext} from './expression'; +import type {Type} from './types'; + +/** + * Returns null if the type matches, or an error message if not. + * + * If `context` is provided, then also push the error to it via + * `context.error()` + * + * @private + */ +function checkSubtype( + expected: Type, + t: Type, + context?: ParsingContext +): ?string { + const error = `Expected ${toString(expected)} but found ${toString(t)} instead.`; + + // Error is a subtype of every type + if (t.kind === 'Error') { + return null; + } + + if (expected.kind === 'Value') { + if (t.kind === 'Value') return null; + const members = [ + NullType, + NumberType, + StringType, + BooleanType, + ColorType, + ObjectType, + array(ValueType) + ]; + + for (const memberType of members) { + if (!checkSubtype(memberType, t)) { + return null; + } + } + + if (context) context.error(error); + return error; + } else if (expected.kind === 'Array') { + if (t.kind === 'Array') { + const itemError = checkSubtype(expected.itemType, t.itemType); + if (itemError) { + if (context) context.error(error); + return error; + } else if (typeof expected.N === 'number' && expected.N !== t.N) { + if (context) context.error(error); + return error; + } else { + return null; + } + } else { + if (context) context.error(error); + return error; + } + } else { + if (t.kind === expected.kind) return null; + if (context) context.error(error); + return error; + } +} + +module.exports = checkSubtype; diff --git a/src/style-spec/function/compile.js b/src/style-spec/function/compile.js new file mode 100644 index 00000000000..fb8808b537e --- /dev/null +++ b/src/style-spec/function/compile.js @@ -0,0 +1,122 @@ +// @flow + +const assert = require('assert'); +module.exports = compileExpression; + +const { + ParsingContext +} = require('./expression'); +const parseExpression = require('./parse_expression'); +const { CompoundExpression } = require('./compound_expression'); +const definitions = require('./definitions'); +const evaluationContext = require('./evaluation_context'); + +import type { Type } from './types.js'; +import type { Expression, ParsingError } from './expression.js'; + +type CompileErrors = {| + result: 'error', + errors: Array +|} + +type CompiledExpression = {| + result: 'success', + function: Function, + functionSource: string, + isFeatureConstant: boolean, + isZoomConstant: boolean, + expression: Expression +|} + +/** + * + * Given a style function expression object, returns: + * ``` + * { + * result: 'success', + * isFeatureConstant: boolean, + * isZoomConstant: boolean, + * function: Function + * } + * ``` + * or else + * + * ``` + * { + * result: 'error', + * errors: Array + * } + * ``` + * + * @private + */ +function compileExpression( + expr: mixed, + expectedType?: Type +): CompiledExpression | CompileErrors { + const context = new ParsingContext(definitions, [], expectedType || null); + const parsed = parseExpression(expr, context); + if (!parsed) { + assert(context.errors.length > 0); + return { + result: 'error', + errors: context.errors + }; + } + + const compiled = parsed.compile(); + if (typeof compiled === 'string') { + const fn = (new Function('$this', '$globalProperties', '$feature', ` +$globalProperties = $globalProperties || {}; +var $props = $feature && $feature.properties || {}; +return $this.unwrap(${compiled}) +`): any); + + return { + result: 'success', + function: (globalProperties, feature) => + fn(evaluationContext(), globalProperties, feature), + functionSource: compiled, + isFeatureConstant: isFeatureConstant(parsed), + isZoomConstant: isZoomConstant(parsed), + expression: parsed + }; + } + + return { + result: 'error', + errors: compiled + }; +} + +function isFeatureConstant(e: Expression) { + let result = true; + e.accept({ + visit: (expression) => { + if (expression instanceof CompoundExpression) { + if (expression.name === 'get') { + result = result && (expression.args.length > 1); + } else if (expression.name === 'has') { + result = result && (expression.args.length > 1); + } else { + result = result && !( + expression.name === 'properties' || + expression.name === 'geometry-type' || + expression.name === 'id' + ); + } + } + } + }); + return result; +} + +function isZoomConstant(e: Expression) { + let result = true; + e.accept({ + visit: (expression) => { + if (expression.name === 'zoom') result = false; + } + }); + return result; +} diff --git a/src/style-spec/function/compound_expression.js b/src/style-spec/function/compound_expression.js new file mode 100644 index 00000000000..a86490247c1 --- /dev/null +++ b/src/style-spec/function/compound_expression.js @@ -0,0 +1,163 @@ +// @flow + +const { toString } = require('./types'); +const { ParsingContext } = require('./expression'); +const parseExpression = require('./parse_expression'); +const checkSubtype = require('./check_subtype'); +const assert = require('assert'); + +import type { Expression } from './expression'; +import type { Type } from './types'; + +type Varargs = {| type: Type |}; +type Signature = Array | Varargs; +type Compile = (args: Array) => string; +type Definition = [Type, Signature, Compile] | + {|type: Type, overloads: Array<[Signature, Compile]>|}; + +class CompoundExpression implements Expression { + key: string; + name: string; + type: Type; + compileFromArgs: Compile; + args: Array; + + static definitions: { [string]: Definition }; + + constructor(key: string, name: string, type: Type, compileFromArgs: Compile, args: Array) { + this.key = key; + this.name = name; + this.type = type; + this.compileFromArgs = compileFromArgs; + this.args = args; + } + + compile(): string { + const compiledArgs: Array = []; + + const args = this.args; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const compiledArg = arg.compile(); + compiledArgs.push(`(${compiledArg})`); + } + + return this.compileFromArgs(compiledArgs); + } + + serialize() { + const name = this.name; + const args = this.args.map(e => e.serialize()); + return [ name ].concat(args); + } + + accept(visitor: Visitor) { + visitor.visit(this); + this.args.forEach(a => a.accept(visitor)); + } + + static parse(args: Array, context: ParsingContext): ?Expression { + const op: string = (args[0]: any); + const definition = CompoundExpression.definitions[op]; + if (!definition) { + return context.error(`Unknown expression "${op}". If you wanted a literal array, use ["literal", [...]].`, 0); + } + + // Now check argument types against each signature + const type = Array.isArray(definition) ? + definition[0] : definition.type; + + const overloads = Array.isArray(definition) ? + [[definition[1], definition[2]]] : + definition.overloads.filter(overload => ( + !Array.isArray(overload[0][0]) || // varags + overload[0][0].length === args.length - 1 // correct param count + )); + + // First parse all the args + const parsedArgs: Array = []; + for (let i = 1; i < args.length; i++) { + const arg = args[i]; + let expected; + if (overloads.length === 1) { + const params = overloads[0][0]; + expected = Array.isArray(params) ? + params[i - 1] : + params.type; + } + const parsed = parseExpression(arg, context.concat(1 + parsedArgs.length, expected)); + if (!parsed) return null; + parsedArgs.push(parsed); + } + + let signatureContext: ParsingContext = (null: any); + + for (const [params, compileFromArgs] of overloads) { + // Use a fresh context for each attempted signature so that, if + // we eventually succeed, we haven't polluted `context.errors`. + signatureContext = new ParsingContext(context.definitions, context.path, null, context.scope); + + if (Array.isArray(params)) { + if (params.length !== parsedArgs.length) { + signatureContext.error(`Expected ${params.length} arguments, but found ${parsedArgs.length} instead.`); + continue; + } + } + + for (let i = 0; i < parsedArgs.length; i++) { + const expected = Array.isArray(params) ? params[i] : params.type; + const arg = parsedArgs[i]; + checkSubtype(expected, arg.type, signatureContext.concat(i + 1)); + } + + if (signatureContext.errors.length === 0) { + return new CompoundExpression(context.key, op, type, compileFromArgs, parsedArgs); + } + } + + assert(signatureContext.errors.length > 0); + + if (overloads.length === 1) { + context.errors.push.apply(context.errors, signatureContext.errors); + } else { + const signatures = overloads + .map(([params]) => stringifySignature(params)) + .join(' | '); + const actualTypes = parsedArgs + .map(arg => toString(arg.type)) + .join(', '); + context.error(`Expected arguments of type ${signatures}, but found (${actualTypes}) instead.`); + } + + return null; + } + + static register( + expressions: { [string]: Class }, + definitions: { [string]: Definition } + ) { + assert(!CompoundExpression.definitions); + CompoundExpression.definitions = definitions; + for (const name in definitions) { + expressions[name] = CompoundExpression; + } + } +} + +function varargs(type: Type): Varargs { + return { type }; +} + +function stringifySignature(signature: Signature): string { + if (Array.isArray(signature)) { + return `(${signature.map(toString).join(', ')})`; + } else { + return `(${toString(signature.type)}...)`; + } +} + +module.exports = { + CompoundExpression, + varargs +}; + diff --git a/src/style-spec/function/convert.js b/src/style-spec/function/convert.js new file mode 100644 index 00000000000..95bc031c032 --- /dev/null +++ b/src/style-spec/function/convert.js @@ -0,0 +1,226 @@ +const assert = require('assert'); +const extend = require('../util/extend'); + +module.exports.function = convertFunction; +module.exports.value = convertValue; + +function convertFunction(parameters, propertySpec) { + let expression; + + parameters = extend({}, parameters); + if (typeof parameters.default !== 'undefined') { + parameters.default = convertValue(parameters.default, propertySpec); + } else { + parameters.default = convertValue(propertySpec.default, propertySpec); + if (parameters.default === null) { + parameters.default = ['error', 'No default property value available.']; + } + } + + if (parameters.stops) { + const zoomAndFeatureDependent = parameters.stops && typeof parameters.stops[0][0] === 'object'; + const featureDependent = zoomAndFeatureDependent || parameters.property !== undefined; + const zoomDependent = zoomAndFeatureDependent || !featureDependent; + + parameters.stops = parameters.stops.map((stop) => { + return [stop[0], convertValue(stop[1], propertySpec)]; + }); + + if (parameters.colorSpace && parameters.colorSpace !== 'rgb') { + throw new Error('Unimplemented'); + } + + if (zoomAndFeatureDependent) { + expression = convertZoomAndPropertyFunction(parameters, propertySpec); + } else if (zoomDependent) { + expression = convertZoomFunction(parameters, propertySpec); + } else { + expression = convertPropertyFunction(parameters, propertySpec); + } + + if (expression[0] === 'curve' && expression[1][0] === 'step' && expression.length === 4) { + // degenerate step curve (i.e. a constant function): add a noop stop + expression.push(0); + expression.push(expression[3]); + } + } else { + // identity function + expression = annotateValue(['get', parameters.property], propertySpec); + } + + return ['coalesce', expression, parameters.default]; +} + +function annotateValue(value, spec) { + if (spec.type === 'color') { + return ['to-color', ['string', value]]; + } else if (spec.type === 'array' && typeof spec.length === 'number') { + return ['array', spec.value, spec.length, value]; + } else if (spec.type === 'array') { + return ['array', spec.value, value]; + } else if (spec.type === 'enum') { + const values = {}; + for (const v in spec.values) { + values[v] = true; + } + return [ + 'let', + 'property_value', + ['string', value], + 'enum_values', + ['literal', values], + [ + 'case', + ['has', ['var', 'property_value'], ['var', 'enum_values']], + ['var', 'property_value'], + [ + 'error', + `Expected value to be one of ${Object.keys(values).join(', ')}.` + + ] + ] + ]; + } else { + return [spec.type, value]; + } +} + +function convertValue(value, spec) { + if (typeof value === 'undefined') return null; + if (spec.type === 'color') { + return ['to-color', value]; + } else if (spec.type === 'array') { + return ['literal', value]; + } else { + return value; + } +} + +function convertZoomAndPropertyFunction(parameters, propertySpec) { + const featureFunctions = {}; + const zoomStops = []; + for (let s = 0; s < parameters.stops.length; s++) { + const stop = parameters.stops[s]; + const zoom = stop[0].zoom; + if (featureFunctions[zoom] === undefined) { + featureFunctions[zoom] = { + zoom: zoom, + type: parameters.type, + property: parameters.property, + default: parameters.default, + stops: [] + }; + zoomStops.push(zoom); + } + featureFunctions[zoom].stops.push([stop[0].value, stop[1]]); + } + + // the interpolation type for the zoom dimension of a zoom-and-property + // function is determined directly from the style property specification + // for which it's being used: linear for interpolatable properties, step + // otherwise. + const functionType = getFunctionType({}, propertySpec); + let interpolationType; + let isStep = false; + if (functionType === 'exponential') { + interpolationType = ['linear']; + } else { + interpolationType = ['step']; + isStep = true; + } + const expression = ['curve', interpolationType, ['zoom']]; + + for (const z of zoomStops) { + appendStopPair(expression, z, convertPropertyFunction(featureFunctions[z], propertySpec), isStep); + } + + return expression; +} + +function convertPropertyFunction(parameters, propertySpec) { + const type = getFunctionType(parameters, propertySpec); + + const inputType = typeof parameters.stops[0][0]; + assert( + inputType === 'string' || + inputType === 'number' || + inputType === 'boolean' + ); + + let input = [inputType, ['get', parameters.property]]; + + let expression; + let isStep = false; + if (type === 'categorical' && inputType === 'boolean') { + assert(parameters.stops.length > 0 && parameters.stops.length <= 2); + if (parameters.stops[0][0] === false) { + input = ['!', input]; + } + expression = [ 'case', input, parameters.stops[0][1] ]; + if (parameters.stops.length > 1) { + expression.push(parameters.stops[1][1]); + } else { + expression.push(parameters.default); + } + return expression; + } else if (type === 'categorical') { + expression = ['match', input]; + } else if (type === 'interval') { + expression = ['curve', ['step'], input]; + isStep = true; + } else if (type === 'exponential') { + const base = parameters.base !== undefined ? parameters.base : 1; + expression = ['curve', ['exponential', base], input]; + } else { + throw new Error(`Unknown property function type ${type}`); + } + + for (const stop of parameters.stops) { + appendStopPair(expression, stop[0], stop[1], isStep); + } + + if (expression[0] === 'match') { + expression.push(parameters.default); + } + + return expression; +} + +function convertZoomFunction(parameters, propertySpec) { + const type = getFunctionType(parameters, propertySpec); + let expression; + let isStep = false; + if (type === 'interval') { + expression = ['curve', ['step'], ['zoom']]; + isStep = true; + } else if (type === 'exponential') { + const base = parameters.base !== undefined ? parameters.base : 1; + expression = ['curve', ['exponential', base], ['zoom']]; + } else { + throw new Error(`Unknown zoom function type "${type}"`); + } + + for (const stop of parameters.stops) { + appendStopPair(expression, stop[0], stop[1], isStep); + } + + return expression; +} + +function appendStopPair(curve, input, output, isStep) { + // step curves don't get the first input value, as it is redundant. + if (!(isStep && curve.length === 3)) { + curve.push(input); + } + curve.push(output); +} + +function getFunctionType (parameters, propertySpec) { + if (parameters.type) { + return parameters.type; + } else if (propertySpec.function) { + return propertySpec.function === 'interpolated' ? 'exponential' : 'interval'; + } else { + return 'exponential'; + } +} diff --git a/src/style-spec/function/definitions/array.js b/src/style-spec/function/definitions/array.js new file mode 100644 index 00000000000..f12c5fb22da --- /dev/null +++ b/src/style-spec/function/definitions/array.js @@ -0,0 +1,87 @@ +// @flow + +const parseExpression = require('../parse_expression'); +const { + toString, + array, + ValueType, + StringType, + NumberType, + BooleanType +} = require('../types'); + +import type { Expression, ParsingContext } from '../expression'; +import type { ArrayType } from '../types'; + +const types = { + string: StringType, + number: NumberType, + boolean: BooleanType +}; + +class ArrayAssertion implements Expression { + key: string; + type: ArrayType; + input: Expression; + + constructor(key: string, type: ArrayType, input: Expression) { + this.key = key; + this.type = type; + this.input = input; + } + + static parse(args: Array, context: ParsingContext): ?Expression { + if (args.length < 2 || args.length > 4) + return context.error(`Expected 1, 2, or 3 arguments, but found ${args.length - 1} instead.`); + + let itemType; + let N; + if (args.length > 2) { + const type = args[1]; + if (typeof type !== 'string' || !(type in types)) + return context.error('The item type argument of "array" must be one of string, number, boolean', 1); + itemType = types[type]; + } else { + itemType = ValueType; + } + + if (args.length > 3) { + if ( + typeof args[2] !== 'number' || + args[2] < 0 || + args[2] !== Math.floor(args[2]) + ) { + return context.error('The length argument to "array" must be a positive integer literal', 2); + } + N = args[2]; + } + + const type = array(itemType, N); + + const input = parseExpression(args[args.length - 1], context.concat(args.length - 1, ValueType)); + if (!input) return null; + + return new ArrayAssertion(context.key, type, input); + } + + compile() { + return `$this.as(${this.input.compile()}, ${JSON.stringify(this.type)})`; + } + + serialize() { + if (typeof this.type.N === 'number') { + return [ 'array', toString(this.type.itemType), this.type.N, this.input.serialize() ]; + } else if (this.type.itemType.kind !== 'value') { + return [ 'array', toString(this.type.itemType), this.input.serialize() ]; + } else { + return [ 'array', this.input.serialize() ]; + } + } + + accept(visitor: Visitor) { + visitor.visit(this); + this.input.accept(visitor); + } +} + +module.exports = ArrayAssertion; diff --git a/src/style-spec/function/definitions/at.js b/src/style-spec/function/definitions/at.js new file mode 100644 index 00000000000..05a0b77f82d --- /dev/null +++ b/src/style-spec/function/definitions/at.js @@ -0,0 +1,54 @@ +// @flow + +const parseExpression = require('../parse_expression'); +const { + array, + ValueType, + NumberType +} = require('../types'); + +import type { Expression, ParsingContext } from '../expression'; +import type { Type, ArrayType } from '../types'; + +class At implements Expression { + key: string; + type: Type; + index: Expression; + input: Expression; + + constructor(key: string, type: Type, index: Expression, input: Expression) { + this.key = key; + this.type = type; + this.index = index; + this.input = input; + } + + static parse(args: Array, context: ParsingContext) { + if (args.length !== 3) + return context.error(`Expected 2 arguments, but found ${args.length - 1} instead.`); + + const index = parseExpression(args[1], context.concat(1, NumberType)); + const input = parseExpression(args[2], context.concat(2, array(context.expectedType || ValueType))); + + if (!index || !input) return null; + + const t: ArrayType = (input.type: any); + return new At(context.key, t.itemType, index, input); + } + + compile() { + return `$this.at(${this.index.compile()}, ${this.input.compile()})`; + } + + serialize() { + return [ 'at', this.index.serialize(), this.input.serialize() ]; + } + + accept(visitor: Visitor) { + visitor.visit(this); + this.index.accept(visitor); + this.input.accept(visitor); + } +} + +module.exports = At; diff --git a/src/style-spec/function/definitions/case.js b/src/style-spec/function/definitions/case.js new file mode 100644 index 00000000000..3025ba599e5 --- /dev/null +++ b/src/style-spec/function/definitions/case.js @@ -0,0 +1,86 @@ +// @flow + +const assert = require('assert'); +const parseExpression = require('../parse_expression'); +const { BooleanType } = require('../types'); + +import type { Expression, ParsingContext } from '../expression'; +import type { Type } from '../types'; + +type Branches = Array<[Expression, Expression]>; + +class Case implements Expression { + key: string; + type: Type; + + branches: Branches; + otherwise: Expression; + + constructor(key: string, type: Type, branches: Branches, otherwise: Expression) { + this.key = key; + this.type = type; + this.branches = branches; + this.otherwise = otherwise; + } + + static parse(args: Array, context: ParsingContext) { + if (args.length < 4) + return context.error(`Expected at least 3 arguments, but found only ${args.length - 1}.`); + if (args.length % 2 !== 0) + return context.error(`Expected an odd number of arguments.`); + + let outputType: ?Type; + if (context.expectedType && context.expectedType.kind !== 'Value') { + outputType = context.expectedType; + } + + const branches = []; + for (let i = 1; i < args.length - 1; i += 2) { + const test = parseExpression(args[i], context.concat(i, BooleanType)); + if (!test) return null; + + const result = parseExpression(args[i + 1], context.concat(i + 1, outputType)); + if (!result) return null; + + branches.push([test, result]); + + outputType = outputType || result.type; + } + + const otherwise = parseExpression(args[args.length - 1], context.concat(args.length - 1, outputType)); + if (!otherwise) return null; + + assert(outputType); + return new Case(context.key, (outputType: any), branches, otherwise); + } + + compile() { + const result = []; + for (const [test, expression] of this.branches) { + result.push(`(${test.compile()}) ? (${expression.compile()})`); + } + result.push(`(${this.otherwise.compile()})`); + return result.join(' : '); + } + + serialize() { + const result = ['case']; + for (const [test, expression] of this.branches) { + result.push(test.serialize()); + result.push(expression.serialize()); + } + result.push(this.otherwise.serialize()); + return result; + } + + accept(visitor: Visitor) { + visitor.visit(this); + for (const [test, expression] of this.branches) { + test.accept(visitor); + expression.accept(visitor); + } + this.otherwise.accept(visitor); + } +} + +module.exports = Case; diff --git a/src/style-spec/function/definitions/coalesce.js b/src/style-spec/function/definitions/coalesce.js new file mode 100644 index 00000000000..5c48ff0d7ee --- /dev/null +++ b/src/style-spec/function/definitions/coalesce.js @@ -0,0 +1,64 @@ +// @flow + +const assert = require('assert'); +const parseExpression = require('../parse_expression'); + +import type { Expression, ParsingContext } from '../expression'; +import type { Type } from '../types'; + +class Coalesce implements Expression { + key: string; + type: Type; + args: Array; + + constructor(key: string, type: Type, args: Array) { + this.key = key; + this.type = type; + this.args = args; + } + + static parse(args: Array, context: ParsingContext) { + if (args.length < 2) { + return context.error("Expectected at least one argument."); + } + let outputType: Type = (null: any); + if (context.expectedType && context.expectedType.kind !== 'Value') { + outputType = context.expectedType; + } + const parsedArgs = []; + for (const arg of args.slice(1)) { + const argContext = context.concat(1 + parsedArgs.length, outputType); + const parsed = parseExpression(arg, argContext); + if (!parsed) return null; + outputType = outputType || parsed.type; + parsedArgs.push(parsed); + } + assert(outputType); + return new Coalesce(context.key, (outputType: any), parsedArgs); + } + + compile() { + const compiledArgs = []; + for (let i = 0; i < this.args.length - 1; i++) { + compiledArgs.push(`try { + var result = ${this.args[i].compile()}; + if (result !== null) return result; + } catch (e) {}`); + } + compiledArgs.push(`return ${this.args[this.args.length - 1].compile()};`); + return `(function coalesce() {\n${compiledArgs.join('\n')}\n})()`; + } + + serialize() { + return ['coalesce'].concat(this.args.map(a => a.serialize())); + } + + accept(visitor: Visitor) { + visitor.visit(this); + for (const arg of this.args) { + arg.accept(visitor); + } + } +} + +module.exports = Coalesce; diff --git a/src/style-spec/function/definitions/contains.js b/src/style-spec/function/definitions/contains.js new file mode 100644 index 00000000000..af611f0b8c7 --- /dev/null +++ b/src/style-spec/function/definitions/contains.js @@ -0,0 +1,60 @@ +// @flow + +const parseExpression = require('../parse_expression'); +const { + array, + BooleanType, + ValueType +} = require('../types'); + +import type { Expression, ParsingContext } from '../expression'; +import type { Type, ArrayType } from '../types'; + +class Contains implements Expression { + key: string; + type: Type; + value: Expression; + array: Expression; + + constructor(key: string, value: Expression, array: Expression) { + this.key = key; + this.type = BooleanType; + this.value = value; + this.array = array; + } + + static parse(args: Array, context: ParsingContext) { + if (args.length !== 3) + return context.error(`Expected 2 arguments, but found ${args.length - 1} instead.`); + + const arrayExpr = parseExpression(args[2], context.concat(2, array(ValueType))); + if (!arrayExpr) return null; + + const t: ArrayType = (arrayExpr.type: any); + const value = parseExpression(args[1], context.concat(1, t.itemType)); + if (!value) return null; + + const itemType = value.type.kind; + if (itemType === 'Object' || itemType === 'Array' || itemType === 'Color') { + return context.error(`"contains" does not support values of type ${itemType}.`); + } + + return new Contains(context.key, value, arrayExpr); + } + + compile() { + return `$this.contains(${this.value.compile()}, ${this.array.compile()})`; + } + + serialize() { + return [ 'contains', this.value.serialize(), this.array.serialize() ]; + } + + accept(visitor: Visitor) { + visitor.visit(this); + this.array.accept(visitor); + this.value.accept(visitor); + } +} + +module.exports = Contains; diff --git a/src/style-spec/function/definitions/curve.js b/src/style-spec/function/definitions/curve.js new file mode 100644 index 00000000000..b6f135bae03 --- /dev/null +++ b/src/style-spec/function/definitions/curve.js @@ -0,0 +1,196 @@ +// @flow + +const UnitBezier = require('@mapbox/unitbezier'); +const interpolationFactor = require('../interpolation_factor'); +const { + toString, + NumberType +} = require('../types'); +const parseExpression = require('../parse_expression'); + +import type { Expression, ParsingContext } from '../expression'; +import type { Type } from '../types'; + +export type InterpolationType = + { name: 'step' } | + { name: 'linear' } | + { name: 'exponential', base: number } | + { name: 'cubic-bezier', controlPoints: [number, number, number, number] }; + +type Stops = Array<[number, Expression]>; + +class Curve implements Expression { + key: string; + type: Type; + + interpolation: InterpolationType; + input: Expression; + stops: Stops; + + constructor(key: string, type: Type, interpolation: InterpolationType, input: Expression, stops: Stops) { + this.key = key; + this.type = type; + this.interpolation = interpolation; + this.input = input; + this.stops = stops; + } + + static interpolationFactor(interpolation: InterpolationType, input: number, lower: number, upper: number, unitBezierCache?: {[string]: UnitBezier}) { + let t = 0; + if (interpolation.name === 'exponential') { + t = interpolationFactor(input, interpolation.base, lower, upper); + } else if (interpolation.name === 'linear') { + t = interpolationFactor(input, 1, lower, upper); + } else if (interpolation.name === 'cubic-bezier') { + const key = interpolation.controlPoints.join(','); + let ub = unitBezierCache ? unitBezierCache[key] : null; + if (!ub) { + ub = new UnitBezier(...interpolation.controlPoints); + if (unitBezierCache) { + unitBezierCache[key] = ub; + } + } + t = ub.solve(interpolationFactor(input, 1, lower, upper)); + } + return t; + } + + static parse(args: Array, context: ParsingContext) { + let [ , interpolation, input, ...rest] = args; + + if (!Array.isArray(interpolation) || interpolation.length === 0) { + return context.error(`Expected an interpolation type expression.`, 1); + } + + if (interpolation[0] === 'step') { + interpolation = { name: 'step' }; + } else if (interpolation[0] === 'linear') { + interpolation = { name: 'linear' }; + } else if (interpolation[0] === 'exponential') { + const base = interpolation[1]; + if (typeof base !== 'number') + return context.error(`Exponential interpolation requires a numeric base.`, 1, 1); + interpolation = { + name: 'exponential', + base + }; + } else if (interpolation[0] === 'cubic-bezier') { + const controlPoints = interpolation.slice(1); + if ( + controlPoints.length !== 4 || + controlPoints.some(t => typeof t !== 'number' || t < 0 || t > 1) + ) { + return context.error('Cubic bezier interpolation requires four numeric arguments with values between 0 and 1.', 1); + } + + interpolation = { + name: 'cubic-bezier', + controlPoints: (controlPoints: any) + }; + } else { + return context.error(`Unknown interpolation type ${String(interpolation[0])}`, 1, 0); + } + + const isStep = interpolation.name === 'step'; + + const minArgs = isStep ? 5 : 4; + if (args.length - 1 < minArgs) + return context.error(`Expected at least ${minArgs} arguments, but found only ${args.length - 1}.`); + + const parity = minArgs % 2; + if ((args.length - 1) % 2 !== parity) { + return context.error(`Expected an ${parity === 0 ? 'even' : 'odd'} number of arguments.`); + } + + input = parseExpression(input, context.concat(2, NumberType)); + if (!input) return null; + + const stops: Stops = []; + + let outputType: Type = (null: any); + if (context.expectedType && context.expectedType.kind !== 'Value') { + outputType = context.expectedType; + } + + if (isStep) { + rest.unshift(-Infinity); + } + + for (let i = 0; i < rest.length; i += 2) { + const label = rest[i]; + const value = rest[i + 1]; + + const labelKey = isStep ? i + 4 : i + 3; + const valueKey = isStep ? i + 5 : i + 4; + + if (typeof label !== 'number') { + return context.error('Input/output pairs for "curve" expressions must be defined using literal numeric values (not computed expressions) for the input values.', labelKey); + } + + if (stops.length && stops[stops.length - 1][0] > label) { + return context.error('Input/output pairs for "curve" expressions must be arranged with input values in strictly ascending order.', labelKey); + } + + const parsed = parseExpression(value, context.concat(valueKey, outputType)); + if (!parsed) return null; + outputType = outputType || parsed.type; + stops.push([label, parsed]); + } + + if (interpolation.name !== 'step' && + outputType.kind !== 'Number' && + outputType.kind !== 'Color' && + !(outputType.kind === 'Array' && outputType.itemType.kind === 'Number')) { + return context.error(`Type ${toString(outputType)} is not interpolatable, and thus cannot be used as a ${interpolation.name} curve's output type.`); + } + + return new Curve(context.key, outputType, interpolation, input, stops); + } + + compile() { + const input = this.input.compile(); + + const labels = []; + const outputs = []; + for (const [label, expression] of this.stops) { + labels.push(label); + outputs.push(`function () { return ${expression.compile()}; }.bind(this)`); + } + + const interpolationType = this.type.kind.toLowerCase(); + + return `$this.evaluateCurve( + ${input}, + [${labels.join(',')}], + [${outputs.join(',')}], + ${JSON.stringify(this.interpolation)}, + ${JSON.stringify(interpolationType)})`; + } + + serialize() { + const result = ['curve']; + const interp = [this.interpolation.name]; + if (this.interpolation.name === 'exponential') { + interp.push(this.interpolation.base); + } else if (this.interpolation.name === 'cubic-bezier') { + interp.push.apply(interp, this.interpolation.controlPoints); + } + result.push(interp); + result.push(this.input.serialize()); + for (const [label, expression] of this.stops) { + result.push(label); + result.push(expression.serialize()); + } + return result; + } + + accept(visitor: Visitor) { + visitor.visit(this); + this.input.accept(visitor); + for (const [ , expression] of this.stops) { + expression.accept(visitor); + } + } +} + +module.exports = Curve; diff --git a/src/style-spec/function/definitions/index.js b/src/style-spec/function/definitions/index.js new file mode 100644 index 00000000000..4453428de0d --- /dev/null +++ b/src/style-spec/function/definitions/index.js @@ -0,0 +1,198 @@ +// @flow + +const assert = require('assert'); + +const { + NullType, + NumberType, + StringType, + BooleanType, + ColorType, + ObjectType, + ValueType, + array, + ErrorType +} = require('../types'); + +const { CompoundExpression, varargs } = require('../compound_expression'); +const Let = require('./let'); +const Var = require('./var'); +const Literal = require('./literal'); +const ArrayAssertion = require('./array'); +const At = require('./at'); +const Contains = require('./contains'); +const Match = require('./match'); +const Case = require('./case'); +const Curve = require('./curve'); +const Coalesce = require('./coalesce'); + +import type { Expression } from '../expression'; +import type { Type } from '../types'; + +const expressions: { [string]: Class } = { + // special forms + 'let': Let, + 'var': Var, + 'literal': Literal, + 'array': ArrayAssertion, + 'at': At, + 'contains': Contains, + 'case': Case, + 'match': Match, + 'coalesce': Coalesce, + 'curve': Curve, +}; + +CompoundExpression.register(expressions, { + 'error': [ ErrorType, [ StringType ], fromContext('error') ], + 'ln2': [ NumberType, [], () => 'Math.LN2'], + 'pi': [ NumberType, [], () => 'Math.PI'], + 'e': [ NumberType, [], () => 'Math.E'], + 'typeof': [ StringType, [ValueType], fromContext('typeOf') ], + 'string': defineAssertion(StringType), + 'number': defineAssertion(NumberType), + 'boolean': defineAssertion(BooleanType), + 'object': defineAssertion(ObjectType), + 'to-string': [ StringType, [ValueType], fromContext('toString') ], + 'to-number': [ NumberType, [ValueType], fromContext('toNumber') ], + 'to-boolean': [ BooleanType, [ValueType], ([v]) => `Boolean(${v})` ], + 'to-rgba': [ array(NumberType, 4), [ColorType], ([v]) => `${v}.value` ], + 'to-color': [ ColorType, [ValueType], fromContext('toColor') ], + 'rgb': [ ColorType, [NumberType, NumberType, NumberType], + fromContext('rgba') ], + 'rgba': [ ColorType, [NumberType, NumberType, NumberType, NumberType], + fromContext('rgba') ], + 'get': { + type: ValueType, + overloads: [ + [[StringType], ([k]) => `$this.get($props, ${k}, 'feature.properties')`], + [[StringType, ObjectType], ([k, obj]) => + `$this.get(${obj}, ${k})` + ] + ] + }, + 'has': { + type: BooleanType, + overloads: [ + [[StringType], ([k]) => `$this.has($props, ${k}, 'feature.properties')`], + [[StringType, ObjectType], ([k, obj]) => + `$this.has(${obj}, ${k})` + ] + ] + }, + 'length': { + type: NumberType, + overloads: [ + [[StringType], ([s]) => `${s}.length`], + [[array(ValueType)], ([arr]) => `${arr}.length`] + ] + }, + 'properties': [ObjectType, [], () => + '$this.as($props, $this.types.Object, "feature.properties")' + ], + 'geometry-type': [ StringType, [], () => + '$this.geometryType($feature)' + ], + 'id': [ ValueType, [], () => + `('id' in $feature) ? $feature.id : null` + ], + 'zoom': [ NumberType, [], () => '$globalProperties.zoom' ], + '+': defineBinaryMathOp('+', true), + '*': defineBinaryMathOp('*', true), + '-': { + type: NumberType, + overloads: [ + [[NumberType, NumberType], ([a, b]) => `${a} - ${b}`], + [[NumberType], ([a]) => `-${a}`] + ] + }, + '/': defineBinaryMathOp('/'), + '%': defineBinaryMathOp('%'), + '^': [ NumberType, [NumberType, NumberType], ([base, exp]) => + `Math.pow(${base}, ${exp})` + ], + 'log10': defineMathFunction('log10', 1), + 'ln': defineMathFunction('log', 1), + 'log2': defineMathFunction('log2', 1), + 'sin': defineMathFunction('sin', 1), + 'cos': defineMathFunction('cos', 1), + 'tan': defineMathFunction('tan', 1), + 'asin': defineMathFunction('asin', 1), + 'acos': defineMathFunction('acos', 1), + 'atan': defineMathFunction('atan', 1), + 'min': [ + NumberType, + varargs(NumberType), + (args) => `Math.min(${args.join(', ')})` + ], + 'max': [ + NumberType, + varargs(NumberType), + (args) => `Math.max(${args.join(', ')})` + ], + '==': defineComparisonOp('=='), + '!=': defineComparisonOp('!='), + '>': defineComparisonOp('>'), + '<': defineComparisonOp('<'), + '>=': defineComparisonOp('>='), + '<=': defineComparisonOp('<='), + '&&': defineBooleanOp('&&'), + '||': defineBooleanOp('||'), + '!': [BooleanType, [BooleanType], ([input]) => `!(${input})`], + // string manipulation + 'upcase': [StringType, [StringType], ([s]) => `(${s}).toUpperCase()`], + 'downcase': [StringType, [StringType], ([s]) => `(${s}).toLowerCase()`], + 'concat': [ StringType, varargs(StringType), (args) => + `[${args.join(', ')}].join('')` + ], +}); + +function defineAssertion(type: Type) { + const typeParameter = type.kind === 'Array' ? JSON.stringify(type) : + `$this.types.${type.kind}`; + return [ type, [ValueType], (args) => + `$this.as(${args[0]}, ${typeParameter})` + ]; +} + +function defineMathFunction(name: string, arity: number) { + assert(typeof Math[name] === 'function'); + assert(arity > 0); + const signature = []; + while (arity-- > 0) signature.push(NumberType); + return [NumberType, signature, (args) => `Math.${name}(${args.join(', ')})`]; +} + +function defineBinaryMathOp(name, isAssociative) { + const signature = isAssociative ? varargs(NumberType) : [NumberType, NumberType]; + return [NumberType, signature, (args) => args.join(name)]; +} + +function defineComparisonOp(name) { + const op = name === '==' ? '===' : + name === '!=' ? '!==' : name; + const compile = ([lhs, rhs]) => `${lhs} ${op} ${rhs}`; + const overloads = [ + [[NumberType, NumberType], compile], + [[StringType, StringType], compile] + ]; + if (name === '==' || name === '!=') { + overloads.push([[BooleanType, BooleanType], compile]); + overloads.push([[NullType, NullType], compile]); + } + return { + type: BooleanType, + overloads + }; +} + +function defineBooleanOp(op) { + return [BooleanType, varargs(BooleanType), (args) => args.join(op)]; +} + +function fromContext(name: string) { + return (args) => `$this.${name}(${args.join(', ')})`; +} + + +module.exports = expressions; diff --git a/src/style-spec/function/definitions/let.js b/src/style-spec/function/definitions/let.js new file mode 100644 index 00000000000..af19b319ba0 --- /dev/null +++ b/src/style-spec/function/definitions/let.js @@ -0,0 +1,93 @@ +// @flow + +const parseExpression = require('../parse_expression'); +import type { Type } from '../types'; +import type { Expression, ParsingContext } from '../expression'; + +class Let implements Expression { + key: string; + type: Type; + bindings: Array<[string, Expression]>; + result: Expression; + + constructor(key: string, bindings: Array<[string, Expression]>, result: Expression) { + this.key = key; + this.type = result.type; + this.bindings = [].concat(bindings); + this.result = result; + } + + compile() { + const names = []; + const values = []; + const errors = []; + for (const [name, expression] of this.bindings) { + names.push(name); + const value = expression.compile(); + if (Array.isArray(value)) { + errors.push.apply(errors, value); + } else { + values.push(value); + } + } + + const result = this.result.compile(); + + return `(function (${names.map(Let.escape).join(', ')}) { + return ${result}; + })(${values.join(', ')})`; + } + + serialize() { + const serialized = ['let']; + for (const [name, expression] of this.bindings) { + serialized.push(name, expression.serialize()); + } + serialized.push(this.result.serialize()); + return serialized; + } + + accept(visitor: Visitor) { + visitor.visit(this); + for (const binding of this.bindings) { + binding[1].accept(visitor); + } + this.result.accept(visitor); + } + + static parse(args: Array, context: ParsingContext) { + if (args.length < 4) + return context.error(`Expected at least 3 arguments, but found ${args.length - 1} instead.`); + + const bindings: Array<[string, Expression]> = []; + for (let i = 1; i < args.length - 1; i += 2) { + const name = args[i]; + + if (typeof name !== 'string') { + return context.error(`Expected string, but found ${typeof name} instead.`, i); + } + + if (/[^a-zA-Z0-9_]/.test(name)) { + return context.error(`Variable names must contain only alphanumeric characters or '_'.`, i); + } + + const value = parseExpression(args[i + 1], context.concat(i + 1)); + if (!value) return null; + + bindings.push([name, value]); + } + + const resultContext = context.concat(args.length - 1, undefined, bindings); + const result = parseExpression(args[args.length - 1], resultContext); + if (!result) return null; + + return new Let(context.key, bindings, result); + } + + // escape variable names to avoid conflict with reserved words / globals + static escape(name: string): string { + return `_${name}`; + } +} + +module.exports = Let; diff --git a/src/style-spec/function/definitions/literal.js b/src/style-spec/function/definitions/literal.js new file mode 100644 index 00000000000..c4b9324f39f --- /dev/null +++ b/src/style-spec/function/definitions/literal.js @@ -0,0 +1,63 @@ +// @flow + +const { Color, isValue, typeOf } = require('../values'); + +import type { Type } from '../types'; +import type { Value } from '../values'; +import type { Expression, ParsingContext } from '../expression'; + +class Literal implements Expression { + key: string; + type: Type; + value: Value; + + constructor(key: *, type: Type, value: Value) { + this.key = key; + this.type = type; + this.value = value; + } + + static parse(args: Array, context: ParsingContext) { + if (args.length !== 2) + return context.error(`'literal' expression requires exactly one argument, but found ${args.length - 1} instead.`); + + if (!isValue(args[1])) + return context.error(`invalid value`); + + const value = (args[1]: any); + let type = typeOf(value); + + // special case: infer the item type if possible for zero-length arrays + const expected = context.expectedType; + if ( + type.kind === 'Array' && + type.N === 0 && + expected && + expected.kind === 'Array' && + (typeof expected.N !== 'number' || expected.N === 0) + ) { + type = expected; + } + + return new Literal(context.key, type, value); + } + + compile() { + const value = JSON.stringify(this.value); + return typeof this.value === 'object' ? `(${value})` : value; + } + + serialize() { + if (this.value === null || typeof this.value === 'string' || typeof this.value === 'boolean' || typeof this.value === 'number') { + return this.value; + } else if (this.value instanceof Color) { + return ["rgba"].concat(this.value.value); + } else { + return ["literal", this.value]; + } + } + + accept(visitor: Visitor) { visitor.visit(this); } +} + +module.exports = Literal; diff --git a/src/style-spec/function/definitions/match.js b/src/style-spec/function/definitions/match.js new file mode 100644 index 00000000000..b365417e680 --- /dev/null +++ b/src/style-spec/function/definitions/match.js @@ -0,0 +1,149 @@ +// @flow + +const assert = require('assert'); +const parseExpression = require('../parse_expression'); +const checkSubtype = require('../check_subtype'); +const { typeOf } = require('../values'); + +import type { Expression, ParsingContext } from '../expression'; +import type { Type } from '../types'; + +// Map input label values to output expression index +type Cases = {[number | string]: number}; + +class Match implements Expression { + key: string; + type: Type; + inputType: Type; + + input: Expression; + cases: Cases; + outputs: Array; + otherwise: Expression; + + constructor(key: string, inputType: Type, outputType: Type, input: Expression, cases: Cases, outputs: Array, otherwise: Expression) { + this.key = key; + this.inputType = inputType; + this.type = outputType; + this.input = input; + this.cases = cases; + this.outputs = outputs; + this.otherwise = otherwise; + } + + static parse(args: Array, context: ParsingContext) { + if (args.length < 5) + return context.error(`Expected at least 4 arguments, but found only ${args.length - 1}.`); + if (args.length % 2 !== 1) + return context.error(`Expected an even number of arguments.`); + + let inputType; + let outputType; + if (context.expectedType && context.expectedType.kind !== 'Value') { + outputType = context.expectedType; + } + const cases = {}; + const outputs = []; + for (let i = 2; i < args.length - 1; i += 2) { + let labels = args[i]; + const value = args[i + 1]; + + if (!Array.isArray(labels)) { + labels = [labels]; + } + + const labelContext = context.concat(i); + if (labels.length === 0) { + return labelContext.error('Expected at least one branch label.'); + } + + for (const label of labels) { + if (typeof label !== 'number' && typeof label !== 'string') { + return labelContext.error(`Branch labels must be numbers or strings.`); + } else if (typeof label === 'number' && Math.abs(label) > Number.MAX_SAFE_INTEGER) { + return labelContext.error(`Branch labels must be integers no larger than ${Number.MAX_SAFE_INTEGER}.`); + + } else if (typeof label === 'number' && Math.floor(label) !== label) { + return labelContext.error(`Numeric branch labels must be integer values.`); + + } else if (!inputType) { + inputType = typeOf(label); + } else if (checkSubtype(inputType, typeOf(label), labelContext)) { + return null; + } + + if (typeof cases[String(label)] !== 'undefined') { + return labelContext.error('Branch labels must be unique.'); + } + + cases[String(label)] = outputs.length; + } + + const result = parseExpression(value, context.concat(i, outputType)); + if (!result) return null; + outputType = outputType || result.type; + outputs.push(result); + } + + const input = parseExpression(args[1], context.concat(1, inputType)); + if (!input) return null; + + const otherwise = parseExpression(args[args.length - 1], context.concat(args.length - 1, outputType)); + if (!otherwise) return null; + + assert(inputType && outputType); + return new Match(context.key, (inputType: any), (outputType: any), input, cases, outputs, otherwise); + } + + compile() { + const input = this.input.compile(); + const outputs = [`function () { return ${this.otherwise.compile()} }`]; + const lookup = {}; + + for (const label in this.cases) { + // shift the index stored in this.cases by one, as we're using + // outputs[0] for the 'otherwise' case. + lookup[`${String(label)}`] = this.cases[label] + 1; + } + for (const output of this.outputs) { + outputs.push(`function () { return ${output.compile()} }`); + } + + return `(function () { + var o = [${outputs.join(', ')}]; + var l = ${JSON.stringify(lookup)}; + var i = ${input}; + return o[l[$this.as(i, ${JSON.stringify(this.inputType)})] || 0](); + })()`; + } + + serialize() { + const result = ['match']; + result.push(this.input.serialize()); + const branches = []; + for (const output of this.outputs) { + branches.push([[], output.serialize()]); + } + for (const label in this.cases) { + const index = this.cases[label]; + branches[index][0].push(label); + } + for (const [labels, expression] of branches) { + result.push(labels); + result.push(expression); + } + result.push(this.otherwise.serialize()); + return result; + } + + accept(visitor: Visitor) { + visitor.visit(this); + this.input.accept(visitor); + for (const output of this.outputs) { + output.accept(visitor); + } + this.otherwise.accept(visitor); + } +} + +module.exports = Match; diff --git a/src/style-spec/function/definitions/var.js b/src/style-spec/function/definitions/var.js new file mode 100644 index 00000000000..2c0f5377c01 --- /dev/null +++ b/src/style-spec/function/definitions/var.js @@ -0,0 +1,41 @@ +// @flow + +const Let = require('./let'); + +import type { Type } from '../types'; +import type { Expression, ParsingContext } from '../expression'; + +class Var implements Expression { + key: string; + type: Type; + name: string; + + constructor(key: string, name: string, type: Type) { + this.key = key; + this.type = type; + this.name = name; + } + + static parse(args: Array, context: ParsingContext) { + if (args.length !== 2 || typeof args[1] !== 'string') + return context.error(`'var' expression requires exactly one string literal argument.`); + + const name = args[1]; + if (!context.scope.has(name)) { + return context.error(`Unknown variable "${name}". Make sure "${name}" has been bound in an enclosing "let" expression before using it.`, 1); + } + + return new Var(context.key, name, context.scope.get(name).type); + } + + compile() { return Let.escape(this.name); } + + serialize() { + return [this.name]; + } + + accept(visitor: Visitor) { visitor.visit(this); } +} + + +module.exports = Var; diff --git a/src/style-spec/function/evaluation_context.js b/src/style-spec/function/evaluation_context.js new file mode 100644 index 00000000000..fd71cb23fe4 --- /dev/null +++ b/src/style-spec/function/evaluation_context.js @@ -0,0 +1,240 @@ +// @flow + +const assert = require('assert'); +const parseColor = require('../util/parse_color'); +const interpolate = require('../util/interpolate'); +const { + NullType, + NumberType, + StringType, + BooleanType, + ColorType, + ObjectType, + ValueType, + toString} = require('./types'); +const {Color, typeOf, isValue} = require('./values'); +const checkSubtype = require('./check_subtype'); +const Curve = require('./definitions/curve'); + +import type UnitBezier from '@mapbox/unitbezier'; +import type { Type } from './types'; +import type { Value } from './values'; +import type { InterpolationType } from './definitions/curve'; +import type { Feature } from './index'; + +const geometryTypes = ['Unknown', 'Point', 'LineString', 'Polygon']; +const types = { + Null: NullType, + Number: NumberType, + String: StringType, + Boolean: BooleanType, + Color: ColorType, + Object: ObjectType, + Value: ValueType +}; + +const jsTypes = { + Number: 'number', + String: 'string', + Boolean: 'boolean', + Object: 'object' +}; + +class RuntimeError { + name: string; + message: string; + constructor(message) { + this.name = 'ExpressionEvaluationError'; + this.message = message; + } + + toJSON() { + return `${this.message}`; + } +} + +// don't call this 'assert' because build/min.test.js checks for 'assert(' +// in the bundled code to verify that unassertify is working. +function ensure(condition: any, message: string) { + if (!condition) throw new RuntimeError(message); + return true; +} + + +module.exports = () => ({ + types: types, + + ensure: ensure, + error: (msg: string) => ensure(false, msg), + + at: function (index: number, array: Array) { + ensure(index >= 0 && index < array.length, + `Array index out of bounds: ${index} > ${array.length}.`); + ensure(index === Math.floor(index), + `Array index must be an integer, but found ${String(index)} instead.`); + return array[index]; + }, + + get: function (obj: {[string]: Value}, key: string, name?: string) { + ensure(this.has(obj, key, name), `Property '${key}' not found in ${name || `object`}`); + return obj[key]; + }, + + has: function (obj: {[string]: Value}, key: string, name?: string) { + ensure(obj, `Cannot get property ${key} from null object${name ? ` ${name}` : ''}.`); + ensure(typeof obj === 'object', `Expected ${name || 'value'} to be of type Object, but found ${toString(typeOf(obj))} instead.`); + return typeof obj[key] !== 'undefined'; + }, + + contains: function (value: Value, array: Array) { + const type = typeOf(value).kind; + ensure(type !== 'Object' && type !== 'Array' && type !== 'Color', + `"contains" does not support values of type ${type}`); + return array.indexOf(value) >= 0; + }, + + typeOf: function (x: Value): string { + return toString(typeOf(x)); + }, + + as: function (value: Value, expectedType: Type, name?: string) { + assert(isValue(value), `Invalid value ${JSON.stringify(value)}`); + assert(expectedType.kind, `Invalid type ${JSON.stringify(expectedType)}`); + + let type; + let typeError = false; + if (expectedType.kind === 'Null') { + typeError = value === null; + } else if (expectedType.kind === 'Value') { + typeError = false; + } else if (expectedType.kind !== 'Array' && expectedType.kind !== 'Color' && expectedType.kind !== 'Error') { + typeError = typeof value !== jsTypes[expectedType.kind]; + } else { + type = typeOf(value); + typeError = checkSubtype(expectedType, type); + } + + if (typeError) { + if (!type) type = typeOf(value); + throw new RuntimeError(`Expected ${name || 'value'} to be of type ${toString(expectedType)}, but found ${toString(type)} instead.`); + } + + return value; + }, + + toColor: function (input: Value) { + if (typeof input === 'string') { + return this.parseColor(input); + } else if (Array.isArray(input) && (input.length === 3 || input.length === 4)) { + return this.rgba(...input); + } else { + throw new RuntimeError(`Could not parse color from value '${JSON.stringify(input)}'`); + } + }, + + _parseColorCache: ({}: {[string]: Color}), + parseColor: function (input: string) { + let cached = this._parseColorCache[input]; + if (!cached) { + const c = parseColor(input); + if (!c) + throw new RuntimeError(`Could not parse color from value '${input}'`); + cached = this._parseColorCache[input] = new Color(...c); + } + return cached; + }, + + rgba: function (r: number, g: number, b: number, a?: number) { + ensure(r >= 0 && r <= 255 && + g >= 0 && g <= 255 && + b >= 0 && b <= 255, `Invalid rgba value [${[r, g, b, a || 1].join(', ')}]: 'r', 'g', and 'b' must be between 0 and 255.`); + ensure(typeof a === 'undefined' || + (a >= 0 && a <= 1), `Invalid rgba value [${[r, g, b, a || 1].join(', ')}]: 'a' must be between 0 and 1.`); + return new Color(r / 255, g / 255, b / 255, a); + }, + + toString: function(value: Value) { + const type = this.typeOf(value); + ensure(value === null || /^(String|Number|Boolean)$/.test(type), `Expected a primitive value in ["string", ...], but found ${type} instead.`); + return String(value); + }, + + toNumber: function(value: Value) { + const num = Number(value); + ensure(value !== null && !isNaN(num), `Could not convert ${JSON.stringify(this.unwrap(value))} to number.`); + return num; + }, + + geometryType: function(feature: Feature) { + return typeof feature.type === 'number' ? + geometryTypes[feature.type] : feature.type; + }, + + unwrap: function (maybeWrapped: Value) { + if (maybeWrapped instanceof Color) { + return maybeWrapped.value; + } + + return maybeWrapped; + }, + + _unitBezierCache: ({}: {[string]: UnitBezier}), + evaluateCurve(input: number, stopInputs: Array, stopOutputs: Array, interpolation: InterpolationType, resultType: string) { + const stopCount = stopInputs.length; + if (stopInputs.length === 1) return stopOutputs[0](); + if (input <= stopInputs[0]) return stopOutputs[0](); + if (input >= stopInputs[stopCount - 1]) return stopOutputs[stopCount - 1](); + + const index = findStopLessThanOrEqualTo(stopInputs, input); + + if (interpolation.name === 'step') { + return stopOutputs[index](); + } + + const lower = stopInputs[index]; + const upper = stopInputs[index + 1]; + const t = Curve.interpolationFactor(interpolation, input, lower, upper, this._unitBezierCache); + + const outputLower = stopOutputs[index](); + const outputUpper = stopOutputs[index + 1](); + + if (resultType === 'color') { + return new Color(...interpolate.color(outputLower.value, outputUpper.value, t)); + } + + if (resultType === 'array') { + return interpolate.array(outputLower, outputUpper, t); + } + + return interpolate[resultType](outputLower, outputUpper, t); + } +}); + +/** + * Returns the index of the last stop <= input, or 0 if it doesn't exist. + * + * @private + */ +function findStopLessThanOrEqualTo(stops, input) { + const n = stops.length; + let lowerIndex = 0; + let upperIndex = n - 1; + let currentIndex = 0; + let currentValue, upperValue; + + while (lowerIndex <= upperIndex) { + currentIndex = Math.floor((lowerIndex + upperIndex) / 2); + currentValue = stops[currentIndex]; + upperValue = stops[currentIndex + 1]; + if (input === currentValue || input > currentValue && input < upperValue) { // Search complete + return currentIndex; + } else if (currentValue < input) { + lowerIndex = currentIndex + 1; + } else if (currentValue > input) { + upperIndex = currentIndex - 1; + } + } + + return Math.max(currentIndex - 1, 0); +} + diff --git a/src/style-spec/function/expression.js b/src/style-spec/function/expression.js new file mode 100644 index 00000000000..87c7e77c40f --- /dev/null +++ b/src/style-spec/function/expression.js @@ -0,0 +1,130 @@ +// @flow + +import type { + Type, +} from './types'; + +export interface Expression { + key: string; + +type: Type; + + static parse(args: Array, context: ParsingContext): ?Expression; // eslint-disable-line no-use-before-define + + compile(): string; + + serialize(): any; + accept(Visitor): void; +} + +class ParsingError extends Error { + key: string; + message: string; + constructor(key: string, message: string) { + super(message); + this.message = message; + this.key = key; + } +} + +/** + * Tracks `let` bindings during expression parsing. + * @private + */ +class Scope { + parent: ?Scope; + bindings: {[string]: Expression}; + constructor(parent?: Scope, bindings: Array<[string, Expression]> = []) { + this.parent = parent; + this.bindings = {}; + for (const [name, expression] of bindings) { + this.bindings[name] = expression; + } + } + + concat(bindings: Array<[string, Expression]>) { + return new Scope(this, bindings); + } + + get(name: string): Expression { + if (this.bindings[name]) { return this.bindings[name]; } + if (this.parent) { return this.parent.get(name); } + throw new Error(`${name} not found in scope.`); + } + + has(name: string): boolean { + if (this.bindings[name]) return true; + return this.parent ? this.parent.has(name) : false; + } +} + +/** + * State associated parsing at a given point in an expression tree. + * @private + */ +class ParsingContext { + definitions: {[string]: Class}; + path: Array; + key: string; + scope: Scope; + errors: Array; + + // The expected type of this expression. Provided only to allow Expression + // implementations to infer argument types: Expression#parse() need not + // check that the output type of the parsed expression matches + // `expectedType`. + expectedType: ?Type; + + constructor( + definitions: *, + path: Array = [], + expectedType: ?Type, + scope: Scope = new Scope(), + errors: Array = [] + ) { + this.definitions = definitions; + this.path = path; + this.key = path.map(part => `[${part}]`).join(''); + this.scope = scope; + this.errors = errors; + this.expectedType = expectedType; + } + + /** + * Returns a copy of this context suitable for parsing the subexpression at + * index `index`, optionally appending to 'let' binding map. + * + * Note that `errors` property, intended for collecting errors while + * parsing, is copied by reference rather than cloned. + * @private + */ + concat(index: number, expectedType?: ?Type, bindings?: Array<[string, Expression]>) { + const path = typeof index === 'number' ? this.path.concat(index) : this.path; + const scope = bindings ? this.scope.concat(bindings) : this.scope; + return new ParsingContext( + this.definitions, + path, + expectedType || null, + scope, + this.errors + ); + } + + /** + * Push a parsing (or type checking) error into the `this.errors` + * @param error The message + * @param keys Optionally specify the source of the error at a child + * of the current expression at `this.key`. + * @private + */ + error(error: string, ...keys: Array) { + const key = `${this.key}${keys.map(k => `[${k}]`).join('')}`; + this.errors.push(new ParsingError(key, error)); + return null; + } +} + +module.exports = { + Scope, + ParsingContext, + ParsingError +}; diff --git a/src/style-spec/function/expression_name.js b/src/style-spec/function/expression_name.js new file mode 100644 index 00000000000..eb484042843 --- /dev/null +++ b/src/style-spec/function/expression_name.js @@ -0,0 +1,69 @@ +// @flow +// This would ideally be in expressions.js, but pulled into separate file +// to avoid circular imports, due to https://github.com/facebook/flow/issues/3249 +export type ExpressionName = + "!" | + "!=" | + "%" | + "&&" | + "*" | + "+" | + "-" | + "/" | + "<" | + "<=" | + "==" | + ">" | + ">=" | + "^" | + "abs" | + "acos" | + "array" | + "asin" | + "at" | + "atan" | + "boolean" | + "case" | + "ceil" | + "coalesce" | + "color" | + "concat" | + "cos" | + "cubic-bezier" | + "curve" | + "downcase" | + "e" | + "floor" | + "geometry_type" | + "get" | + "has" | + "id" | + "length" | + "linear" | + "ln" | + "ln2" | + "log10" | + "log2" | + "match" | + "max" | + "min" | + "number" | + "object" | + "pi" | + "properties" | + "rgb" | + "rgba" | + "round" | + "sin" | + "string" | + "tan" | + "to_boolean" | + "to_number" | + "to_rgba" | + "to_string" | + "typeof" | + "upcase" | + "zoom" | + "||"; + +module.exports = {}; diff --git a/src/style-spec/function/index.js b/src/style-spec/function/index.js index 6f8332c663d..d87bc931b79 100644 --- a/src/style-spec/function/index.js +++ b/src/style-spec/function/index.js @@ -1,307 +1,177 @@ - -const colorSpaces = require('./color_spaces'); -const parseColor = require('../util/parse_color'); -const extend = require('../util/extend'); -const getType = require('../util/get_type'); -const interpolate = require('../util/interpolate'); - -function identityFunction(x) { - return x; -} - -function createFunction(parameters, propertySpec) { - const isColor = propertySpec.type === 'color'; - - let fun; +// @flow + +const compileExpression = require('./compile'); +const convert = require('./convert'); +const { + ColorType, + StringType, + NumberType, + BooleanType, + ValueType, + array +} = require('./types'); +const {CompoundExpression} = require('./compound_expression'); +const Curve = require('./definitions/curve'); +const Coalesce = require('./definitions/coalesce'); +const Let = require('./definitions/let'); + +import type {Expression} from './expression'; + +export type Feature = { + +type: 1 | 2 | 3 | 'Unknown' | 'Point' | 'MultiPoint' | 'LineString' | 'MultiLineString' | 'Polygon' | 'MultiPolygon', + +id?: any, + +properties: {[string]: any} +}; + +export type StyleFunction = (globalProperties: {+zoom?: number}, feature?: Feature) => any; + +type StylePropertySpecification = { + type: 'number', + default?: number +} | { + type: 'string', + default?: string +} | { + type: 'boolean', + default?: boolean +} | { + type: 'enum', + values: {[string]: {}}, + default?: string +} | { + type: 'array', + value: 'number' | 'string' | 'boolean', + length?: number, + default?: Array +}; + +type StylePropertyValue = null | string | number | Array | Array; +type FunctionParameters = DataDrivenPropertyValueSpecification + +function createFunction(parameters: FunctionParameters, propertySpec: StylePropertySpecification): StyleFunction { + let expr; if (!isFunctionDefinition(parameters)) { - if (isColor && parameters) { - parameters = parseColor(parameters); + expr = convert.value(parameters, propertySpec); + if (expr === null) { + expr = getDefaultValue(propertySpec); } - fun = function() { - return parameters; - }; - fun.isFeatureConstant = true; - fun.isZoomConstant = true; - + } else if (typeof parameters === 'object' && parameters !== null && typeof parameters.expression !== 'undefined') { + expr = ['coalesce', parameters.expression, getDefaultValue(propertySpec)]; } else { - const zoomAndFeatureDependent = parameters.stops && typeof parameters.stops[0][0] === 'object'; - const featureDependent = zoomAndFeatureDependent || parameters.property !== undefined; - const zoomDependent = zoomAndFeatureDependent || !featureDependent; - const type = parameters.type || (propertySpec.function === 'interpolated' ? 'exponential' : 'interval'); - - if (isColor) { - parameters = extend({}, parameters); + expr = convert.function(parameters, propertySpec); + } - if (parameters.stops) { - parameters.stops = parameters.stops.map((stop) => { - return [stop[0], parseColor(stop[1])]; - }); + const expectedType = getExpectedType(propertySpec); + const compiled = compileExpression(expr, expectedType); + if (compiled.result === 'success') { + const warningHistory: {[key: string]: boolean} = {}; + const f = function (globalProperties: {+zoom?: number}, feature?: Feature) { + try { + const val = compiled.function(globalProperties, feature); + return val === null ? undefined : val; + } catch (e) { + if (!warningHistory[e.message]) { + warningHistory[e.message] = true; + if (typeof console !== 'undefined') console.warn(e.message); + } + return undefined; } - - if (parameters.default) { - parameters.default = parseColor(parameters.default); - } else { - parameters.default = parseColor(propertySpec.default); + }; + f.isFeatureConstant = compiled.isFeatureConstant; + f.isZoomConstant = compiled.isZoomConstant; + if (!f.isZoomConstant) { + // capture metadata from the curve definition that's needed for + // our prepopulate-and-interpolate approach to paint properties + // that are zoom-and-property dependent. + f.zoomCurve = findZoomCurve(compiled.expression); + if (!(f.zoomCurve instanceof Curve)) { + // should be prevented by validation. + throw new Error(f.zoomCurve ? f.zoomCurve.error : 'Invalid zoom expression'); } } + return f; + } else { + console.log(JSON.stringify(expr, null, 2)); + for (const err of compiled.errors) { + console.log(`${err.key}: ${err.message}`); + } + throw new Error(compiled.errors.map(err => `${err.key}: ${err.message}`).join(', ')); + } +} - let innerFun; - let hashedStops; - let categoricalKeyType; - if (type === 'exponential') { - innerFun = evaluateExponentialFunction; - } else if (type === 'interval') { - innerFun = evaluateIntervalFunction; - } else if (type === 'categorical') { - innerFun = evaluateCategoricalFunction; - - // For categorical functions, generate an Object as a hashmap of the stops for fast searching - hashedStops = Object.create(null); - for (const stop of parameters.stops) { - hashedStops[stop[0]] = stop[1]; - } - - // Infer key type based on first stop key-- used to encforce strict type checking later - categoricalKeyType = typeof parameters.stops[0][0]; - - } else if (type === 'identity') { - innerFun = evaluateIdentityFunction; +module.exports = createFunction; +module.exports.isFunctionDefinition = isFunctionDefinition; +module.exports.getExpectedType = getExpectedType; +module.exports.findZoomCurve = findZoomCurve; + +// Zoom-dependent expressions may only use ["zoom"] as the input to a +// 'top-level' "curve" expression. (The curve may be wrapped in one or more +// "let" or "coalesce" expressions.) +function findZoomCurve(expression: Expression): null | Curve | {key: string, error: string} { + if (expression instanceof Curve) { + const input = expression.input; + if (input instanceof CompoundExpression && input.name === 'zoom') { + return expression; } else { - throw new Error(`Unknown function type "${type}"`); + return null; } - - let outputFunction; - - // If we're interpolating colors in a color system other than RGBA, - // first translate all stop values to that color system, then interpolate - // arrays as usual. The `outputFunction` option lets us then translate - // the result of that interpolation back into RGBA. - if (parameters.colorSpace && parameters.colorSpace !== 'rgb') { - if (colorSpaces[parameters.colorSpace]) { - const colorspace = colorSpaces[parameters.colorSpace]; - // Avoid mutating the parameters value - parameters = JSON.parse(JSON.stringify(parameters)); - for (let s = 0; s < parameters.stops.length; s++) { - parameters.stops[s] = [ - parameters.stops[s][0], - colorspace.forward(parameters.stops[s][1]) - ]; - } - outputFunction = colorspace.reverse; + } else if (expression instanceof Let) { + return findZoomCurve(expression.result); + } else if (expression instanceof Coalesce) { + let result = null; + for (const arg of expression.args) { + const e = findZoomCurve(arg); + if (!e) { + continue; + } else if (e.error) { + return e; + } else if (e instanceof Curve && !result) { + result = e; } else { - throw new Error(`Unknown color space: ${parameters.colorSpace}`); + return { + key: e.key, + error: 'Only one zoom-based curve may be used in a style function.' + }; } - } else { - outputFunction = identityFunction; } - if (zoomAndFeatureDependent) { - const featureFunctions = {}; - const zoomStops = []; - for (let s = 0; s < parameters.stops.length; s++) { - const stop = parameters.stops[s]; - const zoom = stop[0].zoom; - if (featureFunctions[zoom] === undefined) { - featureFunctions[zoom] = { - zoom: zoom, - type: parameters.type, - property: parameters.property, - default: parameters.default, - stops: [] - }; - zoomStops.push(zoom); - } - featureFunctions[zoom].stops.push([stop[0].value, stop[1]]); - } - - const featureFunctionStops = []; - for (const z of zoomStops) { - featureFunctionStops.push([featureFunctions[z].zoom, createFunction(featureFunctions[z], propertySpec)]); - } - fun = function(zoom, feature) { - return outputFunction(evaluateExponentialFunction({ - stops: featureFunctionStops, - base: parameters.base - }, propertySpec, zoom)(zoom, feature)); - }; - fun.isFeatureConstant = false; - fun.isZoomConstant = false; - - } else if (zoomDependent) { - fun = function(zoom) { - return outputFunction(innerFun(parameters, propertySpec, zoom, hashedStops, categoricalKeyType)); - }; - fun.isFeatureConstant = true; - fun.isZoomConstant = false; - } else { - fun = function(zoom, feature) { - const value = feature[parameters.property]; - if (value === undefined) { - return coalesce(parameters.default, propertySpec.default); - } - return outputFunction(innerFun(parameters, propertySpec, value, hashedStops, categoricalKeyType)); - }; - fun.isFeatureConstant = false; - fun.isZoomConstant = true; - } + return result; + } else { + return null; } - - return fun; } -function coalesce(a, b, c) { - if (a !== undefined) return a; - if (b !== undefined) return b; - if (c !== undefined) return c; -} - -function evaluateCategoricalFunction(parameters, propertySpec, input, hashedStops, keyType) { - const evaluated = typeof input === keyType ? hashedStops[input] : undefined; // Enforce strict typing on input - return coalesce(evaluated, parameters.default, propertySpec.default); -} - -function evaluateIntervalFunction(parameters, propertySpec, input) { - // Edge cases - if (getType(input) !== 'number') return coalesce(parameters.default, propertySpec.default); - const n = parameters.stops.length; - if (n === 1) return parameters.stops[0][1]; - if (input <= parameters.stops[0][0]) return parameters.stops[0][1]; - if (input >= parameters.stops[n - 1][0]) return parameters.stops[n - 1][1]; - - const index = findStopLessThanOrEqualTo(parameters.stops, input); - - return parameters.stops[index][1]; -} - -function evaluateExponentialFunction(parameters, propertySpec, input) { - const base = parameters.base !== undefined ? parameters.base : 1; - - // Edge cases - if (getType(input) !== 'number') return coalesce(parameters.default, propertySpec.default); - const n = parameters.stops.length; - if (n === 1) return parameters.stops[0][1]; - if (input <= parameters.stops[0][0]) return parameters.stops[0][1]; - if (input >= parameters.stops[n - 1][0]) return parameters.stops[n - 1][1]; - - const index = findStopLessThanOrEqualTo(parameters.stops, input); - const t = interpolationFactor( - input, base, - parameters.stops[index][0], - parameters.stops[index + 1][0]); - - const outputLower = parameters.stops[index][1]; - const outputUpper = parameters.stops[index + 1][1]; - const interp = interpolate[propertySpec.type] || identityFunction; - - if (typeof outputLower === 'function') { - return function(...args) { - const evaluatedLower = outputLower.apply(undefined, args); - const evaluatedUpper = outputUpper.apply(undefined, args); - // Special case for fill-outline-color, which has no spec default. - if (evaluatedLower === undefined || evaluatedUpper === undefined) { - return undefined; - } - return interp(evaluatedLower, evaluatedUpper, t); - }; +function isFunctionDefinition(value: FunctionParameters): boolean { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } else if (typeof value.expression !== 'undefined') { + return true; + } else { + return Array.isArray(value.stops) || + (typeof value.type === 'string' && value.type === 'identity'); } - - return interp(outputLower, outputUpper, t); } -function evaluateIdentityFunction(parameters, propertySpec, input) { - if (propertySpec.type === 'color') { - input = parseColor(input); - } else if (getType(input) !== propertySpec.type && (propertySpec.type !== 'enum' || !propertySpec.values[input])) { - input = undefined; - } - return coalesce(input, parameters.default, propertySpec.default); +function getDefaultValue(propertySpec) { + return (typeof propertySpec.default !== 'undefined') ? + convert.value(propertySpec.default, propertySpec) : + ['error', 'No default property value available']; } -/** - * Returns the index of the last stop <= input, or 0 if it doesn't exist. - * - * @private - */ -function findStopLessThanOrEqualTo(stops, input) { - const n = stops.length; - let lowerIndex = 0; - let upperIndex = n - 1; - let currentIndex = 0; - let currentValue, upperValue; - - while (lowerIndex <= upperIndex) { - currentIndex = Math.floor((lowerIndex + upperIndex) / 2); - currentValue = stops[currentIndex][0]; - upperValue = stops[currentIndex + 1][0]; - if (input === currentValue || input > currentValue && input < upperValue) { // Search complete - return currentIndex; - } else if (currentValue < input) { - lowerIndex = currentIndex + 1; - } else if (currentValue > input) { - upperIndex = currentIndex - 1; - } +function getExpectedType(spec) { + const types = { + color: ColorType, + string: StringType, + number: NumberType, + enum: StringType, + boolean: BooleanType + }; + + if (spec.type === 'array') { + return array(types[spec.value] || ValueType, spec.length); } - return Math.max(currentIndex - 1, 0); -} - -function isFunctionDefinition(value) { - return typeof value === 'object' && (value.stops || value.type === 'identity'); -} - -/** - * Returns a ratio that can be used to interpolate between exponential function - * stops. - * - * How it works: - * Two consecutive stop values define a (scaled and shifted) exponential - * function `f(x) = a * base^x + b`, where `base` is the user-specified base, - * and `a` and `b` are constants affording sufficient degrees of freedom to fit - * the function to the given stops. - * - * Here's a bit of algebra that lets us compute `f(x)` directly from the stop - * values without explicitly solving for `a` and `b`: - * - * First stop value: `f(x0) = y0 = a * base^x0 + b` - * Second stop value: `f(x1) = y1 = a * base^x1 + b` - * => `y1 - y0 = a(base^x1 - base^x0)` - * => `a = (y1 - y0)/(base^x1 - base^x0)` - * - * Desired value: `f(x) = y = a * base^x + b` - * => `f(x) = y0 + a * (base^x - base^x0)` - * - * From the above, we can replace the `a` in `a * (base^x - base^x0)` and do a - * little algebra: - * ``` - * a * (base^x - base^x0) = (y1 - y0)/(base^x1 - base^x0) * (base^x - base^x0) - * = (y1 - y0) * (base^x - base^x0) / (base^x1 - base^x0) - * ``` - * - * If we let `(base^x - base^x0) / (base^x1 base^x0)`, then we have - * `f(x) = y0 + (y1 - y0) * ratio`. In other words, `ratio` may be treated as - * an interpolation factor between the two stops' output values. - * - * (Note: a slightly different form for `ratio`, - * `(base^(x-x0) - 1) / (base^(x1-x0) - 1) `, is equivalent, but requires fewer - * expensive `Math.pow()` operations.) - * - * @private -*/ -function interpolationFactor(input, base, lowerValue, upperValue) { - const difference = upperValue - lowerValue; - const progress = input - lowerValue; - - if (difference === 0) { - return 0; - } else if (base === 1) { - return progress / difference; - } else { - return (Math.pow(base, progress) - 1) / (Math.pow(base, difference) - 1); - } + return types[spec.type]; } -module.exports = createFunction; -module.exports.isFunctionDefinition = isFunctionDefinition; -module.exports.interpolationFactor = interpolationFactor; -module.exports.findStopLessThanOrEqualTo = findStopLessThanOrEqualTo; diff --git a/src/style-spec/function/interpolation_factor.js b/src/style-spec/function/interpolation_factor.js new file mode 100644 index 00000000000..d24fb21980e --- /dev/null +++ b/src/style-spec/function/interpolation_factor.js @@ -0,0 +1,48 @@ +/** + * Returns a ratio that can be used to interpolate between exponential function + * stops. + * How it works: Two consecutive stop values define a (scaled and shifted) exponential function `f(x) = a * base^x + b`, where `base` is the user-specified base, + * and `a` and `b` are constants affording sufficient degrees of freedom to fit + * the function to the given stops. + * + * Here's a bit of algebra that lets us compute `f(x)` directly from the stop + * values without explicitly solving for `a` and `b`: + * + * First stop value: `f(x0) = y0 = a * base^x0 + b` + * Second stop value: `f(x1) = y1 = a * base^x1 + b` + * => `y1 - y0 = a(base^x1 - base^x0)` + * => `a = (y1 - y0)/(base^x1 - base^x0)` + * + * Desired value: `f(x) = y = a * base^x + b` + * => `f(x) = y0 + a * (base^x - base^x0)` + * + * From the above, we can replace the `a` in `a * (base^x - base^x0)` and do a + * little algebra: + * ``` + * a * (base^x - base^x0) = (y1 - y0)/(base^x1 - base^x0) * (base^x - base^x0) + * = (y1 - y0) * (base^x - base^x0) / (base^x1 - base^x0) + * ``` + * + * If we let `(base^x - base^x0) / (base^x1 base^x0)`, then we have + * `f(x) = y0 + (y1 - y0) * ratio`. In other words, `ratio` may be treated as + * an interpolation factor between the two stops' output values. + * + * (Note: a slightly different form for `ratio`, + * `(base^(x-x0) - 1) / (base^(x1-x0) - 1) `, is equivalent, but requires fewer + * expensive `Math.pow()` operations.) + * + * @private +*/ +module.exports = function interpolationFactor(input, base, lowerValue, upperValue) { + const difference = upperValue - lowerValue; + const progress = input - lowerValue; + + if (difference === 0) { + return 0; + } else if (base === 1) { + return progress / difference; + } else { + return (Math.pow(base, progress) - 1) / (Math.pow(base, difference) - 1); + } +}; + diff --git a/src/style-spec/function/parse_expression.js b/src/style-spec/function/parse_expression.js new file mode 100644 index 00000000000..9a3df3173b5 --- /dev/null +++ b/src/style-spec/function/parse_expression.js @@ -0,0 +1,98 @@ +// @flow + +const assert = require('assert'); + +const checkSubtype = require('./check_subtype'); + +import type {Type} from './types'; +import type {ParsingContext, Expression} from './expression'; +import type {CompoundExpression} from './compound_expression'; + +/** + * Parse the given JSON expression. + * + * @param expectedType If provided, the parsed expression will be checked + * against this type. Additionally, `expectedType` will be pssed to + * Expression#parse(), wherein it may be used to infer child expression types + * + * @private + */ +function parseExpression(expr: mixed, context: ParsingContext): ?Expression { + if (expr === null || typeof expr === 'string' || typeof expr === 'boolean' || typeof expr === 'number') { + expr = ['literal', expr]; + } + + if (Array.isArray(expr)) { + if (expr.length === 0) { + return context.error(`Expected an array with at least one element. If you wanted a literal array, use ["literal", []].`); + } + + const op = expr[0]; + if (typeof op !== 'string') { + context.error(`Expression name must be a string, but found ${typeof op} instead. If you wanted a literal array, use ["literal", [...]].`, 0); + return null; + } + + const Expr = context.definitions[op]; + if (Expr) { + let parsed = Expr.parse(expr, context); + if (!parsed) return null; + const expected = context.expectedType; + const actual = parsed.type; + if (expected) { + // when we expect a specific type but have a Value, wrap it + // in a refining assertion + if (expected.kind !== 'Value' && actual.kind === 'Value') { + parsed = wrapForType(expected, parsed, context); + } else if (expected.kind === 'Color' && actual.kind === 'String') { + parsed = wrapForType(expected, parsed, context); + } + + if (checkSubtype(expected, parsed.type, context)) { + return null; + } + } + + return parsed; + } + + return context.error(`Unknown expression "${op}". If you wanted a literal array, use ["literal", [...]].`, 0); + } else if (typeof expr === 'undefined') { + return context.error(`'undefined' value invalid. Use null instead.`); + } else if (typeof expr === 'object') { + return context.error(`Bare objects invalid. Use ["literal", {...}] instead.`); + } else { + return context.error(`Expected an array, but found ${typeof expr} instead.`); + } +} + +const typeWrappers: {[string]: string} = { + 'Number': 'number', + 'String': 'string', + 'Boolean': 'boolean', + 'Color': 'to-color' +}; + +function wrapForType(expected: Type, expression: Expression, context: ParsingContext) { + const wrapper = typeWrappers[expected.kind]; + if (!wrapper) { + return expression; + } + + // weird workaround for circular dependency between CompoundExpression and + // parseExpression + const CompoundExpr: Class = (context.definitions[wrapper]: any); + + const definition = CompoundExpr.definitions[wrapper]; + + assert( + Array.isArray(definition) && // the wrapper expression has no overloads + Array.isArray(definition[1]) && // its inputs isn't Varargs + definition[1].length === 1 && // it takes one parameter + !checkSubtype(definition[1][0], expression.type) // matching the expression we're trying to wrap + ); + + return new CompoundExpr(expression.key, wrapper, expected, definition[2], [expression]); +} + +module.exports = parseExpression; diff --git a/src/style-spec/function/types.js b/src/style-spec/function/types.js new file mode 100644 index 00000000000..088ff15cf14 --- /dev/null +++ b/src/style-spec/function/types.js @@ -0,0 +1,68 @@ +// @flow + +export type NullTypeT = { kind: 'Null' }; +export type NumberTypeT = { kind: 'Number' }; +export type StringTypeT = { kind: 'String' }; +export type BooleanTypeT = { kind: 'Boolean' }; +export type ColorTypeT = { kind: 'Color' }; +export type ObjectTypeT = { kind: 'Object' }; +export type ValueTypeT = { kind: 'Value' }; +export type ErrorTypeT = { kind: 'Error' }; + +export type Type = + NullTypeT | + NumberTypeT | + StringTypeT | + BooleanTypeT | + ColorTypeT | + ObjectTypeT | + ValueTypeT | + ArrayType | // eslint-disable-line no-use-before-define + ErrorTypeT + +export type ArrayType = { + kind: 'Array', + itemType: Type, + N: ?number +} + +const NullType = { kind: 'Null' }; +const NumberType = { kind: 'Number' }; +const StringType = { kind: 'String' }; +const BooleanType = { kind: 'Boolean' }; +const ColorType = { kind: 'Color' }; +const ObjectType = { kind: 'Object' }; +const ValueType = { kind: 'Value' }; +const ErrorType = { kind: 'Error' }; + +function array(itemType: Type, N: ?number): ArrayType { + return { + kind: 'Array', + itemType, + N + }; +} + +function toString(type: Type): string { + if (type.kind === 'Array') { + const itemType = toString(type.itemType); + return typeof type.N === 'number' ? + `Array<${itemType}, ${type.N}>` : + type.itemType.kind === 'Value' ? 'Array' : `Array<${itemType}>`; + } else { + return type.kind; + } +} + +module.exports = { + NullType, + NumberType, + StringType, + BooleanType, + ColorType, + ObjectType, + ValueType, + array, + ErrorType, + toString +}; diff --git a/src/style-spec/function/values.js b/src/style-spec/function/values.js new file mode 100644 index 00000000000..b1ad4e7e8da --- /dev/null +++ b/src/style-spec/function/values.js @@ -0,0 +1,95 @@ +// @flow + +const assert = require('assert'); + +const { + NullType, + NumberType, + StringType, + BooleanType, + ColorType, + ObjectType, + ValueType, + array +} = require('./types'); + +import type { Type } from './types'; + +class Color { + value: [number, number, number, number]; + constructor(r: number, g: number, b: number, a: number = 1) { + this.value = [r, g, b, a]; + } +} + +export type Value = null | string | boolean | number | Color | Array | { [string]: Value } + +function isValue(mixed: mixed): boolean { + if (mixed === null) { + return true; + } else if (typeof mixed === 'string') { + return true; + } else if (typeof mixed === 'boolean') { + return true; + } else if (typeof mixed === 'number') { + return true; + } else if (mixed instanceof Color) { + return true; + } else if (Array.isArray(mixed)) { + for (const item of mixed) { + if (!isValue(item)) { + return false; + } + } + return true; + } else if (typeof mixed === 'object') { + for (const key in mixed) { + if (!isValue(mixed[key])) { + return false; + } + } + return true; + } else { + return false; + } +} + +function typeOf(value: Value): Type { + if (value === null) { + return NullType; + } else if (typeof value === 'string') { + return StringType; + } else if (typeof value === 'boolean') { + return BooleanType; + } else if (typeof value === 'number') { + return NumberType; + } else if (value instanceof Color) { + return ColorType; + } else if (Array.isArray(value)) { + const length = value.length; + let itemType: ?Type; + + for (const item of value) { + const t = typeOf(item); + if (!itemType) { + itemType = t; + } else if (itemType === t) { + continue; + } else { + itemType = ValueType; + break; + } + } + + return array(itemType || ValueType, length); + } else { + assert(typeof value === 'object'); + return ObjectType; + } +} + +module.exports = { + Color, + isValue, + typeOf +}; diff --git a/src/style-spec/reference/v8.json b/src/style-spec/reference/v8.json index 6ca9d13797b..b677a50a28c 100644 --- a/src/style-spec/reference/v8.json +++ b/src/style-spec/reference/v8.json @@ -1755,6 +1755,10 @@ "doc": "The geometry type for the filter to select." }, "function": { + "expression": { + "type": "expression", + "doc": "An expression." + }, "stops": { "type": "array", "doc": "An array of stops.", @@ -1823,6 +1827,201 @@ "length": 2, "doc": "Zoom level and value pair." }, + "expression": { + "type": "array", + "value": "*", + "minimum": 1, + "doc": "An expression defines a function that can be used for data-driven style properties or feature filters." + }, + "expression_name": { + "doc": "", + "type": "enum", + "values": { + "let": { + "doc": "Binds expressions to named variables, which can then be referenced in the result expression using [\"var\", \"variable_name\"]." + }, + "var": { + "doc": "References variable bound using \"let\"." + }, + "literal": { + "doc": "Provides a literal array or object value." + }, + "array": { + "doc": "Asserts that the input is an array (optinally with a specific item type and length)." + }, + "at": { + "doc": "Retrieves an item from an array." + }, + "contains": { + "doc": "Tests whether a value exists in an array." + }, + "case": { + "doc": "Yields the value of the first output expression whose corresponding test evaluates to true." + }, + "match": { + "doc": "Yields the output value whose label value matches the input, or the fallback value if no match is found." + }, + "coalesce": { + "doc": "Evaluates each expression in turn until the first non-null, non-error value is obtained, and returns that value" + }, + "curve": { + "doc": "Interpolates an output value from the given input/output pairs using the specified interpolation strategy. The input levels must be numeric literals in strictly ascending order." + }, + "ln2": { + "doc": "Returns mathematical constant ln(2)." + }, + "pi": { + "doc": "Returns the mathematical constant pi." + }, + "e": { + "doc": "Returns the mathematical constant e." + }, + "typeof": { + "doc": "Returns a string describing the type of the given value." + }, + "string": { + "doc": "Asserts that the input value is a String." + }, + "number": { + "doc": "Asserts that the input value is a Number." + }, + "boolean": { + "doc": "Asserts that the input value is a Boolean." + }, + "object": { + "doc": "Asserts that the input value is an Objects." + }, + "to-string": { + "doc": "Coerces the input value to a String." + }, + "to-number": { + "doc": "Coerces the input value to a Number, if possible." + }, + "to-boolean": { + "doc": "Coerces the input value to a Boolean." + }, + "to-rgba": { + "doc": "Returns the an array of the given color's r, g, b, a components." + }, + "to-color": { + "doc": "Coerces the input value to a Color." + }, + "rgb": { + "doc": "Creates a color value from r, g, b components." + }, + "rgba": { + "doc": "Creates a color value from r, g, b, a components." + }, + "get": { + "doc": "Retrieves an the object property value. If it's not provided, the object argument defaults to [\"properties\"]." + }, + "has": { + "doc": "Tests for the presence of an object property value. If it's not provided, the object argument defaults to [\"properties\"]." + }, + "length": { + "doc": "Gets the length of an array or string." + }, + "properties": { + "doc": "Gets the feature properties object. Note that in some cases, it may be more efficient to use [\"get\", \"property_name\"] directly." + }, + "geometry-type": { + "doc": "Gets the feature's geometry type: Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon." + }, + "id": { + "doc": "Gets the feature's id, if it has one." + }, + "zoom": { + "doc": "Gets the current zoom level. Note that in style layout and paint properties, [\"zoom\"] may only appear as the input to a top-level [\"curve\"] expression." + }, + "+": { + "doc": "" + }, + "*": { + "doc": "" + }, + "-": { + "doc": "" + }, + "/": { + "doc": "" + }, + "%": { + "doc": "" + }, + "^": { + "doc": "" + }, + "log10": { + "doc": "" + }, + "ln": { + "doc": "" + }, + "log2": { + "doc": "" + }, + "sin": { + "doc": "" + }, + "cos": { + "doc": "" + }, + "tan": { + "doc": "" + }, + "asin": { + "doc": "" + }, + "acos": { + "doc": "" + }, + "atan": { + "doc": "" + }, + "min": { + "doc": "" + }, + "max": { + "doc": "" + }, + "==": { + "doc": "" + }, + "!=": { + "doc": "" + }, + ">": { + "doc": "" + }, + "<": { + "doc": "" + }, + ">=": { + "doc": "" + }, + "<=": { + "doc": "" + }, + "&&": { + "doc": "" + }, + "||": { + "doc": "" + }, + "!": { + "doc": "" + }, + "upcase": { + "doc": "" + }, + "downcase": { + "doc": "" + }, + "concat": { + "doc": "Concetenate the given strings." + } + } + }, "light": { "anchor": { "type": "enum", diff --git a/src/style-spec/util/parse_color.js b/src/style-spec/util/parse_color.js index 8fd4547695a..ad27a314af2 100644 --- a/src/style-spec/util/parse_color.js +++ b/src/style-spec/util/parse_color.js @@ -1,7 +1,8 @@ +// @flow const parseColorString = require('csscolorparser').parseCSSColor; -module.exports = function parseColor(input) { +module.exports = function parseColor(input: string | [number, number, number, number]): ?[number, number, number, number] { if (typeof input === 'string') { const rgba = parseColorString(input); if (!rgba) { return undefined; } diff --git a/src/style-spec/validate/validate_expression.js b/src/style-spec/validate/validate_expression.js new file mode 100644 index 00000000000..9dafe7f79f0 --- /dev/null +++ b/src/style-spec/validate/validate_expression.js @@ -0,0 +1,39 @@ + +const ValidationError = require('../error/validation_error'); +const {findZoomCurve, getExpectedType} = require('../function'); +const compile = require('../function/compile'); +const Curve = require('../function/definitions/curve'); +const unbundle = require('../util/unbundle_jsonlint'); + +module.exports = function validateExpression(options) { + const expression = deepUnbundle(options.value.expression); + const compiled = compile(expression, getExpectedType(options.valueSpec)); + + const key = `${options.key}.expression`; + + if (compiled.result === 'success') { + if (!options.disallowNestedZoom || compiled.isZoomConstant) { + return []; + } + + const curve = findZoomCurve(compiled.expression); + if (curve instanceof Curve) { + return []; + } else if (curve) { + return [new ValidationError(`${key}${curve.key}`, options.value, curve.error)]; + } else { + return [new ValidationError(`${key}`, options.value, '"zoom" expression may only be used as input to a top-level "curve" expression.')]; + } + } + + return compiled.errors.map((error) => { + return new ValidationError(`${key}${error.key}`, options.value, error.message); + }); +}; + +function deepUnbundle (value) { + if (Array.isArray(value)) { + return value.map(deepUnbundle); + } + return unbundle(value); +} diff --git a/src/style-spec/validate/validate_function.js b/src/style-spec/validate/validate_function.js index 621f2bfb0b8..8d8ea9bbdef 100644 --- a/src/style-spec/validate/validate_function.js +++ b/src/style-spec/validate/validate_function.js @@ -5,9 +5,17 @@ const validate = require('./validate'); const validateObject = require('./validate_object'); const validateArray = require('./validate_array'); const validateNumber = require('./validate_number'); +const validateExpression = require('./validate_expression'); const unbundle = require('../util/unbundle_jsonlint'); +const extend = require('../util/extend'); module.exports = function validateFunction(options) { + if (options.value.expression) { + return validateExpression(extend({}, options, { + disallowNestedZoom: true + })); + } + const functionValueSpec = options.valueSpec; const functionType = unbundle(options.value.type); let stopKeyType; @@ -130,7 +138,7 @@ module.exports = function validateFunction(options) { valueSpec: {}, style: options.style, styleSpec: options.styleSpec - })); + }, value)); } return errors.concat(validate({ @@ -142,18 +150,20 @@ module.exports = function validateFunction(options) { })); } - function validateStopDomainValue(options) { + function validateStopDomainValue(options, stop) { const type = getType(options.value); const value = unbundle(options.value); + const reportValue = options.value !== null ? options.value : stop; + if (!stopKeyType) { stopKeyType = type; } else if (type !== stopKeyType) { - return [new ValidationError(options.key, options.value, '%s stop domain type must match previous stop domain type %s', type, stopKeyType)]; + return [new ValidationError(options.key, reportValue, '%s stop domain type must match previous stop domain type %s', type, stopKeyType)]; } if (type !== 'number' && type !== 'string' && type !== 'boolean') { - return [new ValidationError(options.key, options.value, 'stop domain value must be a number, string, or boolean')]; + return [new ValidationError(options.key, reportValue, 'stop domain value must be a number, string, or boolean')]; } if (type !== 'number' && functionType !== 'categorical') { @@ -161,21 +171,21 @@ module.exports = function validateFunction(options) { if (functionValueSpec['property-function'] && functionType === undefined) { message += '\nIf you intended to use a categorical function, specify `"type": "categorical"`.'; } - return [new ValidationError(options.key, options.value, message, type)]; + return [new ValidationError(options.key, reportValue, message, type)]; } if (functionType === 'categorical' && type === 'number' && (!isFinite(value) || Math.floor(value) !== value)) { - return [new ValidationError(options.key, options.value, 'integer expected, found %s', value)]; + return [new ValidationError(options.key, reportValue, 'integer expected, found %s', value)]; } if (functionType !== 'categorical' && type === 'number' && previousStopDomainValue !== undefined && value < previousStopDomainValue) { - return [new ValidationError(options.key, options.value, 'stop domain values must appear in ascending order')]; + return [new ValidationError(options.key, reportValue, 'stop domain values must appear in ascending order')]; } else { previousStopDomainValue = value; } if (functionType === 'categorical' && value in stopDomainValues) { - return [new ValidationError(options.key, options.value, 'stop domain values must be unique')]; + return [new ValidationError(options.key, reportValue, 'stop domain values must be unique')]; } else { stopDomainValues[value] = true; } diff --git a/src/style-spec/validate/validate_object.js b/src/style-spec/validate/validate_object.js index 49391fbf6a0..2ceb3a1a39b 100644 --- a/src/style-spec/validate/validate_object.js +++ b/src/style-spec/validate/validate_object.js @@ -43,7 +43,7 @@ module.exports = function validateObject(options) { styleSpec: styleSpec, object: object, objectKey: objectKey - })); + }, object)); } for (const elementSpecKey in elementSpecs) { diff --git a/src/style/style_declaration.js b/src/style/style_declaration.js index ad47368c207..1d8951ee7c3 100644 --- a/src/style/style_declaration.js +++ b/src/style/style_declaration.js @@ -2,8 +2,9 @@ const createFunction = require('../style-spec/function'); const util = require('../util/util'); +const Curve = require('../style-spec/function/definitions/curve'); -type StyleFunction = (zoom?: number, featureProperties?: {}) => any; +import type {StyleFunction, Feature} from '../style-spec/function'; /** * A style property declaration @@ -18,7 +19,7 @@ class StyleDeclaration { minimum: number; function: StyleFunction; stopZoomLevels: Array; - _functionInterpolationT: StyleFunction; + _zoomCurve: ?Curve; constructor(reference: any, value: any) { this.value = util.clone(value); @@ -32,36 +33,17 @@ class StyleDeclaration { this.isFeatureConstant = this.function.isFeatureConstant; this.isZoomConstant = this.function.isZoomConstant; - if (!this.isFeatureConstant && !this.isZoomConstant) { + if (!this.isZoomConstant) { + this._zoomCurve = this.function.zoomCurve; 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]); - } - } - - this._functionInterpolationT = createFunction({ - type: 'exponential', - stops: interpolationAmountStops, - base: value.base - }, { - type: 'number' - }); - } else if (!this.isZoomConstant) { - this.stopZoomLevels = []; - for (const stop of this.value.stops) { - if (this.stopZoomLevels.indexOf(stop[0]) < 0) { - this.stopZoomLevels.push(stop[0]); - } + for (const stop of this.function.zoomCurve.stops) { + this.stopZoomLevels.push(stop[0]); } } } - calculate(globalProperties?: {zoom: number}, featureProperties?: {}) { - const value = this.function(globalProperties && globalProperties.zoom, featureProperties || {}); + calculate(globalProperties: {+zoom?: number} = {}, feature?: Feature) { + const value = this.function(globalProperties, feature); if (this.minimum !== undefined && value < this.minimum) { return this.minimum; } @@ -69,17 +51,20 @@ class StyleDeclaration { } /** - * Given a zoom level, calculate a possibly-fractional "index" into the - * composite function stops array, intended to be used for interpolating - * between paint values that have been evaluated at the surrounding stop - * values. + * Calculate the interpolation factor for the given zoom stops and current + * zoom level. * * Only valid for composite functions. * @private */ - calculateInterpolationT(globalProperties: {zoom: number}) { - if (this.isFeatureConstant || this.isZoomConstant) return 0; - return this._functionInterpolationT(globalProperties && globalProperties.zoom, {}); + interpolationFactor(zoom: number, lower: number, upper: number) { + if (!this._zoomCurve) return 0; + return Curve.interpolationFactor( + this._zoomCurve.interpolation, + zoom, + lower, + upper + ); } } diff --git a/src/style/style_layer.js b/src/style/style_layer.js index 2084555bbdd..6e8569aa351 100644 --- a/src/style/style_layer.js +++ b/src/style/style_layer.js @@ -10,6 +10,7 @@ const Evented = require('../util/evented'); import type {Bucket, BucketParameters} from '../data/bucket'; import type Point from '@mapbox/point-geometry'; +import type {Feature} from '../style-spec/function'; export type GlobalProperties = { zoom: number @@ -37,11 +38,11 @@ class StyleLayer extends Evented { _paintSpecifications: any; _layoutSpecifications: any; - _paintTransitions: any; - _paintTransitionOptions: any; - _paintDeclarations: any; - _layoutDeclarations: any; - _layoutFunctions: any; + _paintTransitions: {[string]: StyleTransition}; + _paintTransitionOptions: {[string]: {[string]: TransitionSpecification}}; + _paintDeclarations: {[string]: {[string]: StyleDeclaration}}; + _layoutDeclarations: {[string]: StyleDeclaration}; + _layoutFunctions: {[string]: boolean}; +createBucket: (parameters: BucketParameters) => Bucket; +queryRadius: (bucket: Bucket) => number; @@ -74,7 +75,7 @@ class StyleLayer extends Evented { this._layoutSpecifications = styleSpec[`layout_${this.type}`]; this._paintTransitions = {}; // {[propertyName]: StyleTransition} - this._paintTransitionOptions = {}; // {[className]: {[propertyName]: { duration:Number, delay:Number }}} + this._paintTransitionOptions = {}; // this._paintDeclarations = {}; // {[className]: {[propertyName]: StyleDeclaration}} this._layoutDeclarations = {}; // {[propertyName]: StyleDeclaration} this._layoutFunctions = {}; // {[propertyName]: Boolean} @@ -125,12 +126,12 @@ class StyleLayer extends Evented { ); } - getLayoutValue(name: string, globalProperties?: GlobalProperties, featureProperties?: FeatureProperties): any { + getLayoutValue(name: string, globalProperties?: GlobalProperties, feature?: Feature): any { const specification = this._layoutSpecifications[name]; const declaration = this._layoutDeclarations[name]; if (declaration) { - return declaration.calculate(globalProperties, featureProperties); + return declaration.calculate(globalProperties, feature); } else { return specification.default; } @@ -178,12 +179,12 @@ class StyleLayer extends Evented { } } - getPaintValue(name: string, globalProperties?: GlobalProperties, featureProperties?: FeatureProperties): any { + getPaintValue(name: string, globalProperties?: GlobalProperties, feature?: Feature): any { const specification = this._paintSpecifications[name]; const transition = this._paintTransitions[name]; if (transition) { - return transition.calculate(globalProperties, featureProperties); + return transition.calculate(globalProperties, feature); } else if (specification.type === 'color' && specification.default) { return parseColor(specification.default); } else { @@ -210,14 +211,14 @@ class StyleLayer extends Evented { } } - getPaintInterpolationT(name: string, globalProperties: any) { + getPaintInterpolationFactor(name: string, input: number, lower: number, upper: number) { const transition = this._paintTransitions[name]; - return transition.declaration.calculateInterpolationT(globalProperties); + return transition.declaration.interpolationFactor(input, lower, upper); } - getLayoutInterpolationT(name: string, globalProperties: any) { + getLayoutInterpolationFactor(name: string, input: number, lower: number, upper: number) { const declaration = this._layoutDeclarations[name]; - return declaration.calculateInterpolationT(globalProperties); + return declaration.interpolationFactor(input, lower, upper); } isPaintValueFeatureConstant(name: string) { diff --git a/src/style/style_layer/circle_style_layer.js b/src/style/style_layer/circle_style_layer.js index d570c85f4de..2ff161cae3d 100644 --- a/src/style/style_layer/circle_style_layer.js +++ b/src/style/style_layer/circle_style_layer.js @@ -26,10 +26,10 @@ class CircleStyleLayer extends StyleLayer { bearing: number, pixelsToTileUnits: number): boolean { const translatedPolygon = translate(queryGeometry, - this.getPaintValue('circle-translate', {zoom}, feature.properties), - this.getPaintValue('circle-translate-anchor', {zoom}, feature.properties), + this.getPaintValue('circle-translate', {zoom}, feature), + this.getPaintValue('circle-translate-anchor', {zoom}, feature), bearing, pixelsToTileUnits); - const circleRadius = this.getPaintValue('circle-radius', {zoom}, feature.properties) * pixelsToTileUnits; + const circleRadius = this.getPaintValue('circle-radius', {zoom}, feature) * pixelsToTileUnits; return multiPolygonIntersectsBufferedMultiPoint(translatedPolygon, geometry, circleRadius); } } diff --git a/src/style/style_layer/fill_extrusion_style_layer.js b/src/style/style_layer/fill_extrusion_style_layer.js index 0645378f328..8dab7f5a2f9 100644 --- a/src/style/style_layer/fill_extrusion_style_layer.js +++ b/src/style/style_layer/fill_extrusion_style_layer.js @@ -5,14 +5,15 @@ const FillExtrusionBucket = require('../../data/bucket/fill_extrusion_bucket'); const {multiPolygonIntersectsMultiPolygon} = require('../../util/intersection_tests'); const {translateDistance, translate} = require('../query_utils'); -import type {GlobalProperties, FeatureProperties} from '../style_layer'; +import type {Feature} from '../../style-spec/function'; +import type {GlobalProperties} from '../style_layer'; import type {BucketParameters} from '../../data/bucket'; import type Point from '@mapbox/point-geometry'; class FillExtrusionStyleLayer extends StyleLayer { - getPaintValue(name: string, globalProperties?: GlobalProperties, featureProperties?: FeatureProperties) { - const value = super.getPaintValue(name, globalProperties, featureProperties); + getPaintValue(name: string, globalProperties?: GlobalProperties, feature?: Feature) { + const value = super.getPaintValue(name, globalProperties, feature); if (name === 'fill-extrusion-color' && value) { value[3] = 1; } @@ -34,8 +35,8 @@ class FillExtrusionStyleLayer extends StyleLayer { bearing: number, pixelsToTileUnits: number): boolean { const translatedPolygon = translate(queryGeometry, - this.getPaintValue('fill-extrusion-translate', {zoom}, feature.properties), - this.getPaintValue('fill-extrusion-translate-anchor', {zoom}, feature.properties), + this.getPaintValue('fill-extrusion-translate', {zoom}, feature), + this.getPaintValue('fill-extrusion-translate-anchor', {zoom}, feature), bearing, pixelsToTileUnits); return multiPolygonIntersectsMultiPolygon(translatedPolygon, geometry); } diff --git a/src/style/style_layer/fill_style_layer.js b/src/style/style_layer/fill_style_layer.js index 81a93c2bfb0..d020b1aac53 100644 --- a/src/style/style_layer/fill_style_layer.js +++ b/src/style/style_layer/fill_style_layer.js @@ -5,17 +5,18 @@ const FillBucket = require('../../data/bucket/fill_bucket'); const {multiPolygonIntersectsMultiPolygon} = require('../../util/intersection_tests'); const {translateDistance, translate} = require('../query_utils'); -import type {GlobalProperties, FeatureProperties} from '../style_layer'; +import type {Feature} from '../../style-spec/function'; +import type {GlobalProperties} from '../style_layer'; import type {BucketParameters} from '../../data/bucket'; import type Point from '@mapbox/point-geometry'; class FillStyleLayer extends StyleLayer { - getPaintValue(name: string, globalProperties?: GlobalProperties, featureProperties?: FeatureProperties) { + getPaintValue(name: string, globalProperties?: GlobalProperties, feature?: Feature) { if (name === 'fill-outline-color') { // Special-case handling of undefined fill-outline-color values if (this.getPaintProperty('fill-outline-color') === undefined) { - return super.getPaintValue('fill-color', globalProperties, featureProperties); + return super.getPaintValue('fill-color', globalProperties, feature); } // Handle transitions from fill-outline-color: undefined @@ -28,14 +29,14 @@ class FillStyleLayer extends StyleLayer { ); if (!declaredValue) { - return super.getPaintValue('fill-color', globalProperties, featureProperties); + return super.getPaintValue('fill-color', globalProperties, feature); } transition = transition.oldTransition; } } - return super.getPaintValue(name, globalProperties, featureProperties); + return super.getPaintValue(name, globalProperties, feature); } getPaintValueStopZoomLevels(name: string) { @@ -46,11 +47,11 @@ class FillStyleLayer extends StyleLayer { } } - getPaintInterpolationT(name: string, globalProperties: GlobalProperties) { + getPaintInterpolationFactor(name: string, ...args: *) { if (name === 'fill-outline-color' && this.getPaintProperty('fill-outline-color') === undefined) { - return super.getPaintInterpolationT('fill-color', globalProperties); + return super.getPaintInterpolationFactor('fill-color', ...args); } else { - return super.getPaintInterpolationT(name, globalProperties); + return super.getPaintInterpolationFactor(name, ...args); } } @@ -85,8 +86,8 @@ class FillStyleLayer extends StyleLayer { bearing: number, pixelsToTileUnits: number): boolean { const translatedPolygon = translate(queryGeometry, - this.getPaintValue('fill-translate', {zoom}, feature.properties), - this.getPaintValue('fill-translate-anchor', {zoom}, feature.properties), + this.getPaintValue('fill-translate', {zoom}, feature), + this.getPaintValue('fill-translate-anchor', {zoom}, feature), bearing, pixelsToTileUnits); return multiPolygonIntersectsMultiPolygon(translatedPolygon, geometry); } diff --git a/src/style/style_layer/line_style_layer.js b/src/style/style_layer/line_style_layer.js index 7c11a0ece89..416ad421bc3 100644 --- a/src/style/style_layer/line_style_layer.js +++ b/src/style/style_layer/line_style_layer.js @@ -30,13 +30,13 @@ class LineStyleLayer extends StyleLayer { bearing: number, pixelsToTileUnits: number): boolean { const translatedPolygon = translate(queryGeometry, - this.getPaintValue('line-translate', {zoom}, feature.properties), - this.getPaintValue('line-translate-anchor', {zoom}, feature.properties), + this.getPaintValue('line-translate', {zoom}, feature), + this.getPaintValue('line-translate-anchor', {zoom}, feature), bearing, pixelsToTileUnits); const halfWidth = pixelsToTileUnits / 2 * getLineWidth( - this.getPaintValue('line-width', {zoom}, feature.properties), - this.getPaintValue('line-gap-width', {zoom}, feature.properties)); - const lineOffset = this.getPaintValue('line-offset', {zoom}, feature.properties); + this.getPaintValue('line-width', {zoom}, feature), + this.getPaintValue('line-gap-width', {zoom}, feature)); + const lineOffset = this.getPaintValue('line-offset', {zoom}, feature); if (lineOffset) { geometry = offsetLine(geometry, lineOffset * pixelsToTileUnits); } diff --git a/src/style/style_layer/symbol_style_layer.js b/src/style/style_layer/symbol_style_layer.js index 69c607d6a6d..b137f15d4f0 100644 --- a/src/style/style_layer/symbol_style_layer.js +++ b/src/style/style_layer/symbol_style_layer.js @@ -4,13 +4,14 @@ const StyleLayer = require('../style_layer'); const SymbolBucket = require('../../data/bucket/symbol_bucket'); const assert = require('assert'); -import type {GlobalProperties, FeatureProperties} from '../style_layer'; +import type {Feature} from '../../style-spec/function'; +import type {GlobalProperties} from '../style_layer'; import type {BucketParameters} from '../../data/bucket'; class SymbolStyleLayer extends StyleLayer { - getLayoutValue(name: string, globalProperties?: GlobalProperties, featureProperties?: FeatureProperties) { - const value = super.getLayoutValue(name, globalProperties, featureProperties); + getLayoutValue(name: string, globalProperties?: GlobalProperties, feature?: Feature) { + const value = super.getLayoutValue(name, globalProperties, feature); if (value !== 'auto') { return value; } @@ -18,11 +19,11 @@ class SymbolStyleLayer extends StyleLayer { switch (name) { case 'text-rotation-alignment': case 'icon-rotation-alignment': - return this.getLayoutValue('symbol-placement', globalProperties, featureProperties) === 'line' ? 'map' : 'viewport'; + return this.getLayoutValue('symbol-placement', globalProperties, feature) === 'line' ? 'map' : 'viewport'; case 'text-pitch-alignment': - return this.getLayoutValue('text-rotation-alignment', globalProperties, featureProperties); + return this.getLayoutValue('text-rotation-alignment', globalProperties, feature); case 'icon-pitch-alignment': - return this.getLayoutValue('icon-rotation-alignment', globalProperties, featureProperties); + return this.getLayoutValue('icon-rotation-alignment', globalProperties, feature); default: return value; } diff --git a/src/style/style_transition.js b/src/style/style_transition.js index a9cdedd174b..a1a72a66705 100644 --- a/src/style/style_transition.js +++ b/src/style/style_transition.js @@ -1,9 +1,11 @@ // @flow +const assert = require('assert'); const util = require('../util/util'); const interpolate = require('../style-spec/util/interpolate'); import type StyleDeclaration from './style_declaration'; +import type {Feature} from '../style-spec/function'; const fakeZoomHistory = { lastIntegerZoom: 0, lastIntegerZoomTime: 0, lastZoom: 0 }; @@ -56,8 +58,8 @@ class StyleTransition { /* * Return the value of the transitioning property. */ - calculate(globalProperties: {zoom: number}, featureProperties?: {}, time?: number) { - const value = this._calculateTargetValue(globalProperties, featureProperties); + calculate(globalProperties?: {zoom: number}, feature?: Feature, time?: number) { + const value = this._calculateTargetValue(globalProperties, feature); if (this.instant()) return value; @@ -67,22 +69,23 @@ class StyleTransition { if (time >= this.endTime) return value; - const oldValue = (this.oldTransition: any).calculate(globalProperties, featureProperties, this.startTime); + const oldValue = (this.oldTransition: any).calculate(globalProperties, feature, this.startTime); const t = util.easeCubicInOut((time - this.startTime - this.delay) / this.duration); return this.interp(oldValue, value, t); } - _calculateTargetValue(globalProperties: {zoom: number}, featureProperties?: {}) { + _calculateTargetValue(globalProperties?: {zoom: number}, feature?: Feature) { if (!this.zoomTransitioned) - return this.declaration.calculate(globalProperties, featureProperties); + return this.declaration.calculate(globalProperties, feature); // calculate zoom transition between discrete values, such as images and dasharrays. - const z = globalProperties.zoom; + assert(globalProperties && typeof globalProperties.zoom === 'number'); + const z: number = (globalProperties: any).zoom; const lastIntegerZoom = this.zoomHistory.lastIntegerZoom; const fromScale = z > lastIntegerZoom ? 2 : 0.5; - const from = this.declaration.calculate({zoom: z > lastIntegerZoom ? z - 1 : z + 1}, featureProperties); - const to = this.declaration.calculate({zoom: z}, featureProperties); + const from = this.declaration.calculate({zoom: z > lastIntegerZoom ? z - 1 : z + 1}, feature); + const to = this.declaration.calculate({zoom: z}, feature); const timeFraction = Math.min((Date.now() - this.zoomHistory.lastIntegerZoomTime) / this.duration, 1); const zoomFraction = Math.abs(z - lastIntegerZoom); diff --git a/src/symbol/quads.js b/src/symbol/quads.js index 1807d4dbe84..0e1f01e29f4 100644 --- a/src/symbol/quads.js +++ b/src/symbol/quads.js @@ -5,6 +5,7 @@ const Point = require('@mapbox/point-geometry'); import type Anchor from './anchor'; import type {PositionedIcon, Shaping} from './shaping'; import type StyleLayer from '../style/style_layer'; +import type {Feature} from '../style-spec/function'; module.exports = { getIconQuads, @@ -49,7 +50,7 @@ function getIconQuads(anchor: Anchor, alongLine: boolean, shapedText: Shaping, globalProperties: Object, - featureProperties: Object): Array { + feature: Feature): Array { const image = shapedIcon.image; const layout = layer.layout; @@ -95,7 +96,7 @@ function getIconQuads(anchor: Anchor, bl = new Point(left, bottom); } - const angle = layer.getLayoutValue('icon-rotate', globalProperties, featureProperties) * Math.PI / 180; + const angle = layer.getLayoutValue('icon-rotate', globalProperties, feature) * Math.PI / 180; if (angle) { const sin = Math.sin(angle), @@ -128,11 +129,11 @@ function getGlyphQuads(anchor: Anchor, layer: StyleLayer, alongLine: boolean, globalProperties: Object, - featureProperties: Object): Array { + feature: Feature): Array { const oneEm = 24; - const textRotate = layer.getLayoutValue('text-rotate', globalProperties, featureProperties) * Math.PI / 180; - const textOffset = layer.getLayoutValue('text-offset', globalProperties, featureProperties).map((t)=> t * oneEm); + const textRotate = layer.getLayoutValue('text-rotate', globalProperties, feature) * Math.PI / 180; + const textOffset = layer.getLayoutValue('text-offset', globalProperties, feature).map((t)=> t * oneEm); const positionedGlyphs = shaping.positionedGlyphs; const quads = []; diff --git a/src/symbol/symbol_size.js b/src/symbol/symbol_size.js index 4ae46572532..7fc8a2485c0 100644 --- a/src/symbol/symbol_size.js +++ b/src/symbol/symbol_size.js @@ -1,9 +1,7 @@ // @flow const interpolate = require('../style-spec/util/interpolate'); -const {interpolationFactor} = require('../style-spec/function'); const util = require('../util/util'); -const assert = require('assert'); import type StyleLayer from '../style/style_layer'; @@ -12,27 +10,31 @@ module.exports = { evaluateSizeForZoom }; -type SizeData = { - isFeatureConstant: boolean, - isZoomConstant: boolean, - functionBase: number, - coveringZoomRange: [number, number], - coveringStopValues: [number, number], +export type SizeData = { + functionType: 'constant', layoutSize: number +} | { + functionType: 'camera', + layoutSize: number, + coveringZoomRange: [number, number], + coveringStopValues: [number, number] +} | { + functionType: 'source' +} | { + functionType: 'composite', + coveringZoomRange: [number, number] }; function evaluateSizeForFeature(sizeData: SizeData, partiallyEvaluatedSize: { uSize: number, uSizeT: number }, symbol: { lowerSize: number, upperSize: number}) { const part = partiallyEvaluatedSize; - if (sizeData.isFeatureConstant) { - return part.uSize; + if (sizeData.functionType === 'source') { + return symbol.lowerSize / 10; + } else if (sizeData.functionType === 'composite') { + return interpolate.number(symbol.lowerSize / 10, symbol.upperSize / 10, part.uSizeT); } else { - if (sizeData.isZoomConstant) { - return symbol.lowerSize / 10; - } else { - return interpolate.number(symbol.lowerSize / 10, symbol.upperSize / 10, part.uSizeT); - } + return part.uSize; } } @@ -41,40 +43,29 @@ function evaluateSizeForZoom(sizeData: SizeData, layer: StyleLayer, isText: boolean) { const sizeUniforms = {}; - if (!sizeData.isZoomConstant && !sizeData.isFeatureConstant) { - // composite function - const t = interpolationFactor(tr.zoom, - sizeData.functionBase, + if (sizeData.functionType === 'composite') { + const t = layer.getLayoutInterpolationFactor( + isText ? 'text-size' : 'icon-size', + tr.zoom, sizeData.coveringZoomRange[0], - sizeData.coveringZoomRange[1] - ); + sizeData.coveringZoomRange[1]); sizeUniforms.uSizeT = util.clamp(t, 0, 1); - } else if (sizeData.isFeatureConstant && !sizeData.isZoomConstant) { - // camera function - let size; - if (sizeData.functionType === 'interval') { - size = layer.getLayoutValue(isText ? 'text-size' : 'icon-size', - {zoom: tr.zoom}); - } else { - assert(sizeData.functionType === 'exponential'); - // Even though we could get the exact value of the camera function - // at z = tr.zoom, we intentionally do not: instead, we interpolate - // between the camera function values at a pair of zoom stops covering - // [tileZoom, tileZoom + 1] in order to be consistent with this - // restriction on composite functions - const t = sizeData.functionType === 'interval' ? 0 : - interpolationFactor(tr.zoom, - sizeData.functionBase, - sizeData.coveringZoomRange[0], - sizeData.coveringZoomRange[1]); - - const lowerValue = sizeData.coveringStopValues[0]; - const upperValue = sizeData.coveringStopValues[1]; - size = lowerValue + (upperValue - lowerValue) * util.clamp(t, 0, 1); - } + } else if (sizeData.functionType === 'camera') { + // Even though we could get the exact value of the camera function + // at z = tr.zoom, we intentionally do not: instead, we interpolate + // between the camera function values at a pair of zoom stops covering + // [tileZoom, tileZoom + 1] in order to be consistent with this + // restriction on composite functions + const t = layer.getLayoutInterpolationFactor( + isText ? 'text-size' : 'icon-size', + tr.zoom, + sizeData.coveringZoomRange[0], + sizeData.coveringZoomRange[1]); - sizeUniforms.uSize = size; - } else if (sizeData.isFeatureConstant && sizeData.isZoomConstant) { + const lowerValue = sizeData.coveringStopValues[0]; + const upperValue = sizeData.coveringStopValues[1]; + sizeUniforms.uSize = lowerValue + (upperValue - lowerValue) * util.clamp(t, 0, 1); + } else if (sizeData.functionType === 'constant') { sizeUniforms.uSize = sizeData.layoutSize; } return sizeUniforms; diff --git a/src/symbol/transform_text.js b/src/symbol/transform_text.js index 10a391a9403..1cd05b7ce07 100644 --- a/src/symbol/transform_text.js +++ b/src/symbol/transform_text.js @@ -3,9 +3,10 @@ const rtlTextPlugin = require('../source/rtl_text_plugin'); import type StyleLayer from '../style/style_layer'; +import type {Feature} from '../style-spec/function'; -module.exports = function(text: string, layer: StyleLayer, globalProperties: Object, featureProperties: Object) { - const transform = layer.getLayoutValue('text-transform', globalProperties, featureProperties); +module.exports = function(text: string, layer: StyleLayer, globalProperties: Object, feature: Feature) { + const transform = layer.getLayoutValue('text-transform', globalProperties, feature); if (transform === 'uppercase') { text = text.toLocaleUpperCase(); } else if (transform === 'lowercase') { diff --git a/test/expression.test.js b/test/expression.test.js new file mode 100644 index 00000000000..ff6e6b20ec9 --- /dev/null +++ b/test/expression.test.js @@ -0,0 +1,70 @@ +'use strict'; + +require('flow-remove-types/register'); +const expressionSuite = require('./integration').expression; +const compileExpression = require('../src/style-spec/function/compile'); +const { toString } = require('../src/style-spec/function/types'); + +let tests; + +if (process.argv[1] === __filename && process.argv.length > 2) { + tests = process.argv.slice(2); +} + +expressionSuite.run('js', {tests: tests}, (fixture) => { + let type; + if (fixture.expectExpressionType) { + type = fixture.expectExpressionType; + } + const compiled = compileExpression(fixture.expression, type); + + const result = { + compiled: {} + }; + [ + 'result', + 'functionSource', + 'isFeatureConstant', + 'isZoomConstant', + 'errors' + ].forEach(key => { + if (compiled.hasOwnProperty(key)) { + result.compiled[key] = compiled[key]; + } + }); + if (compiled.result === 'success') { + result.compiled.type = toString(compiled.expression.type); + + const evaluate = fixture.inputs || []; + const evaluateResults = []; + for (const input of evaluate) { + try { + const feature = { properties: input[1].properties || {} }; + if ('id' in input[1]) { + feature.id = input[1].id; + } + if ('geometry' in input[1]) { + feature.type = input[1].geometry.type; + } + const output = compiled.function(input[0], feature); + evaluateResults.push(output); + } catch (error) { + if (error.name === 'ExpressionEvaluationError') { + evaluateResults.push({ error: error.toJSON() }); + } else { + evaluateResults.push({ error: error.message }); + } + } + } + if (fixture.inputs) { + result.outputs = evaluateResults; + } + } else { + result.compiled.errors = result.compiled.errors.map((err) => ({ + key: err.key, + error: err.message + })); + } + + return result; +}); diff --git a/test/integration/expression-tests/acos/basic/test.json b/test/integration/expression-tests/acos/basic/test.json new file mode 100644 index 00000000000..8b07837427f --- /dev/null +++ b/test/integration/expression-tests/acos/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["acos", 0.5], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [1.0471975511965976] + } +} diff --git a/test/integration/expression-tests/and/basic/test.json b/test/integration/expression-tests/and/basic/test.json new file mode 100644 index 00000000000..2cf70a470d0 --- /dev/null +++ b/test/integration/expression-tests/and/basic/test.json @@ -0,0 +1,17 @@ +{ + "expectExpressionType": null, + "expression": ["&&", ["boolean", ["get", "x"]], ["boolean", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": true, "y": false}}], + [{}, {"properties": {"x": true, "y": true}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, true] + } +} diff --git a/test/integration/expression-tests/array/basic/test.json b/test/integration/expression-tests/array/basic/test.json new file mode 100644 index 00000000000..980dcee2013 --- /dev/null +++ b/test/integration/expression-tests/array/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["array", ["literal", [1, 2, 3]]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [[1, 2, 3]] + } +} diff --git a/test/integration/expression-tests/array/item-type-and-length/test.json b/test/integration/expression-tests/array/item-type-and-length/test.json new file mode 100644 index 00000000000..84d1ae07d6d --- /dev/null +++ b/test/integration/expression-tests/array/item-type-and-length/test.json @@ -0,0 +1,26 @@ +{ + "expectExpressionType": null, + "expression": ["array", "number", 2, ["get", "x"]], + "inputs": [ + [{}, {"properties": {"x": [1, 0]}}], + [{}, {"properties": {"x": [0]}}], + [{}, {"properties": {"x": [1, 2, 3]}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [ + [1, 0], + { + "error": "Expected value to be of type Array, but found Array instead." + }, + { + "error": "Expected value to be of type Array, but found Array instead." + } + ] + } +} diff --git a/test/integration/expression-tests/array/item-type/test.json b/test/integration/expression-tests/array/item-type/test.json new file mode 100644 index 00000000000..4563a00e138 --- /dev/null +++ b/test/integration/expression-tests/array/item-type/test.json @@ -0,0 +1,26 @@ +{ + "expectExpressionType": null, + "expression": ["array", "string", ["get", "x"]], + "inputs": [ + [{}, {"properties": {"x": ["a", "b"]}}], + [{}, {"properties": {"x": [1, 2]}}], + [{}, {"properties": {"x": [1, "b"]}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [ + ["a", "b"], + { + "error": "Expected value to be of type Array, but found Array instead." + }, + { + "error": "Expected value to be of type Array, but found Array instead." + } + ] + } +} diff --git a/test/integration/expression-tests/asin/basic/test.json b/test/integration/expression-tests/asin/basic/test.json new file mode 100644 index 00000000000..efcd20c8c59 --- /dev/null +++ b/test/integration/expression-tests/asin/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["asin", 0.5], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [0.5235987755982988] + } +} diff --git a/test/integration/expression-tests/at/basic/test.json b/test/integration/expression-tests/at/basic/test.json new file mode 100644 index 00000000000..c55e7f53bae --- /dev/null +++ b/test/integration/expression-tests/at/basic/test.json @@ -0,0 +1,27 @@ +{ + "expectExpressionType": null, + "expression": [ + "number", + ["at", ["number", ["get", "i"]], ["array", ["get", "arr"]]] + ], + "inputs": [ + [{}, {"properties": {"i": 1, "arr": [9, 8, 7]}}], + [{}, {"properties": {"i": -1, "arr": [9, 8, 7]}}], + [{}, {"properties": {"i": 4, "arr": [9, 8, 7]}}], + [{}, {"properties": {"i": 1.5, "arr": [9, 8, 7]}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [ + 8, + {"error": "Array index out of bounds: -1 > 3."}, + {"error": "Array index out of bounds: 4 > 3."}, + {"error": "Array index must be an integer, but found 1.5 instead."} + ] + } +} diff --git a/test/integration/expression-tests/at/infer-array-type/test.json b/test/integration/expression-tests/at/infer-array-type/test.json new file mode 100644 index 00000000000..3c08f04d6d4 --- /dev/null +++ b/test/integration/expression-tests/at/infer-array-type/test.json @@ -0,0 +1,16 @@ +{ + "expectExpressionType": {"kind": "String"}, + "expression": ["at", 1, ["literal", [1, 2, 3]]], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[2]", + "error": "Expected Array but found Array instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/atan/basic/test.json b/test/integration/expression-tests/atan/basic/test.json new file mode 100644 index 00000000000..a66dfa58f45 --- /dev/null +++ b/test/integration/expression-tests/atan/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["atan", 1], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [0.7853981633974483] + } +} diff --git a/test/integration/expression-tests/boolean/basic/test.json b/test/integration/expression-tests/boolean/basic/test.json new file mode 100644 index 00000000000..73cfc3167d9 --- /dev/null +++ b/test/integration/expression-tests/boolean/basic/test.json @@ -0,0 +1,40 @@ +{ + "expectExpressionType": null, + "expression": ["boolean", ["get", "x"]], + "inputs": [ + [{}, {}], + [{}, {"properties": {"x": true}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": ""}}], + [{}, {"properties": {"x": "false"}}], + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": null}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [ + {"error": "Property 'x' not found in feature.properties"}, + true, + false, + { + "error": "Expected value to be of type Boolean, but found String instead." + }, + { + "error": "Expected value to be of type Boolean, but found String instead." + }, + { + "error": "Expected value to be of type Boolean, but found Number instead." + }, + { + "error": "Expected value to be of type Boolean, but found Number instead." + }, + {"error": "Expected value to be of type Boolean, but found Null instead."} + ] + } +} diff --git a/test/integration/expression-tests/case/basic/test.json b/test/integration/expression-tests/case/basic/test.json new file mode 100644 index 00000000000..a99147607e3 --- /dev/null +++ b/test/integration/expression-tests/case/basic/test.json @@ -0,0 +1,28 @@ +{ + "expectExpressionType": {"kind": "String"}, + "expression": ["case", ["get", "x"], "x", ["get", "y"], "y", "otherwise"], + "inputs": [ + [{}, {"properties": {"x": true, "y": true}}], + [{}, {"properties": {"x": true, "y": false}}], + [{}, {"properties": {"x": false, "y": true}}], + [{}, {"properties": {"x": false, "y": false}}], + [{}, {"properties": {"x": "false", "y": false}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "String" + }, + "outputs": [ + "x", + "x", + "y", + "otherwise", + { + "error": "Expected value to be of type Boolean, but found String instead." + } + ] + } +} diff --git a/test/integration/expression-tests/case/infer-array-type/test.json b/test/integration/expression-tests/case/infer-array-type/test.json new file mode 100644 index 00000000000..c5052c203bf --- /dev/null +++ b/test/integration/expression-tests/case/infer-array-type/test.json @@ -0,0 +1,19 @@ +{ + "expectExpressionType": {"kind": "Array", "itemType": {"kind": "String"}}, + "expression": [ + "case", + ["boolean", ["get", "x"]], + ["literal", ["one"]], + ["literal", ["one", "two"]] + ], + "inputs": [], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [] + } +} diff --git a/test/integration/expression-tests/case/precedence/test.json b/test/integration/expression-tests/case/precedence/test.json new file mode 100644 index 00000000000..01bea3af9b3 --- /dev/null +++ b/test/integration/expression-tests/case/precedence/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["boolean", ["&&", false, ["case", true, true, true]]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false] + } +} diff --git a/test/integration/expression-tests/coalesce/basic/test.json b/test/integration/expression-tests/coalesce/basic/test.json new file mode 100644 index 00000000000..ae1a8e58cef --- /dev/null +++ b/test/integration/expression-tests/coalesce/basic/test.json @@ -0,0 +1,26 @@ +{ + "expectExpressionType": null, + "expression": [ + "coalesce", + ["number", ["get", "x"]], + ["number", ["get", "y"]], + ["number", ["get", "z"]], + 0 + ], + "inputs": [ + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": 1, "y": 2, "z": 3}}], + [{}, {"properties": {"y": 2}}], + [{}, {"properties": {"z": 3}}], + [{}, {}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [1, 1, 2, 3, 0] + } +} diff --git a/test/integration/expression-tests/coalesce/error/test.json b/test/integration/expression-tests/coalesce/error/test.json new file mode 100644 index 00000000000..15067b244dc --- /dev/null +++ b/test/integration/expression-tests/coalesce/error/test.json @@ -0,0 +1,31 @@ +{ + "expectExpressionType": null, + "expression": [ + "coalesce", + ["number", ["get", "x"]], + ["number", ["get", "y"]], + ["number", ["get", "z"]] + ], + "inputs": [ + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": 1, "y": 2, "z": 3}}], + [{}, {"properties": {"y": 2}}], + [{}, {"properties": {"z": 3}}], + [{}, {}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [ + 1, + 1, + 2, + 3, + {"error": "Property 'z' not found in feature.properties"} + ] + } +} diff --git a/test/integration/expression-tests/coalesce/infer-array-type/test.json b/test/integration/expression-tests/coalesce/infer-array-type/test.json new file mode 100644 index 00000000000..968095658ef --- /dev/null +++ b/test/integration/expression-tests/coalesce/infer-array-type/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": {"kind": "Array", "itemType": {"kind": "String"}}, + "expression": [ + "coalesce", + ["literal", ["one"]], + ["literal", ["one", "two"]], + null + ], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[3]", + "error": "Expected Array but found Null instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/concat/basic/test.json b/test/integration/expression-tests/concat/basic/test.json new file mode 100644 index 00000000000..a0056b70558 --- /dev/null +++ b/test/integration/expression-tests/concat/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["concat", "a", "b", "c"], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "String" + }, + "outputs": ["abc"] + } +} diff --git a/test/integration/expression-tests/contains/array/test.json b/test/integration/expression-tests/contains/array/test.json new file mode 100644 index 00000000000..f84d4d81126 --- /dev/null +++ b/test/integration/expression-tests/contains/array/test.json @@ -0,0 +1,16 @@ +{ + "expectExpressionType": null, + "expression": ["contains", ["literal", []], ["array", ["get", "arr"]]], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "\"contains\" does not support values of type Array." + } + ] + } + } +} diff --git a/test/integration/expression-tests/contains/boolean/test.json b/test/integration/expression-tests/contains/boolean/test.json new file mode 100644 index 00000000000..0327ef59110 --- /dev/null +++ b/test/integration/expression-tests/contains/boolean/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": null, + "expression": [ + "contains", + ["get", "item"], + ["literal", [false, false]] + ], + "inputs": [ + [{}, {"properties": {"item": true}}], + [{}, {"properties": {"item": false}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, true] + } +} diff --git a/test/integration/expression-tests/contains/color/test.json b/test/integration/expression-tests/contains/color/test.json new file mode 100644 index 00000000000..9d9a5749b12 --- /dev/null +++ b/test/integration/expression-tests/contains/color/test.json @@ -0,0 +1,16 @@ +{ + "expectExpressionType": null, + "expression": ["contains", ["to-color", "red"], ["array", ["get", "arr"]]], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "\"contains\" does not support values of type Color." + } + ] + } + } +} diff --git a/test/integration/expression-tests/contains/number/test.json b/test/integration/expression-tests/contains/number/test.json new file mode 100644 index 00000000000..3ff753b36e9 --- /dev/null +++ b/test/integration/expression-tests/contains/number/test.json @@ -0,0 +1,28 @@ +{ + "expectExpressionType": null, + "expression": [ + "contains", + ["number", ["get", "item"]], + ["literal", [1, 2, 3, 4]] + ], + "inputs": [ + [{}, {"properties": {"item": 3}}], + [{}, {"properties": {"item": 5}}], + [{}, {"properties": {"item": "3"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [ + true, + false, + { + "error": "Expected value to be of type Number, but found String instead." + } + ] + } +} diff --git a/test/integration/expression-tests/contains/object/test.json b/test/integration/expression-tests/contains/object/test.json new file mode 100644 index 00000000000..865cf7055ef --- /dev/null +++ b/test/integration/expression-tests/contains/object/test.json @@ -0,0 +1,16 @@ +{ + "expectExpressionType": null, + "expression": ["contains", ["literal", {}], ["array", ["get", "arr"]]], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "\"contains\" does not support values of type Object." + } + ] + } + } +} diff --git a/test/integration/expression-tests/contains/string/test.json b/test/integration/expression-tests/contains/string/test.json new file mode 100644 index 00000000000..d7c90cffc1b --- /dev/null +++ b/test/integration/expression-tests/contains/string/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": null, + "expression": [ + "contains", + ["get", "item"], + ["literal", ["a", "b", "c"]] + ], + "inputs": [ + [{}, {"properties": {"item": "a"}}], + [{}, {"properties": {"item": "d"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [true, false] + } +} diff --git a/test/integration/expression-tests/contains/value/test.json b/test/integration/expression-tests/contains/value/test.json new file mode 100644 index 00000000000..3825d85e6e3 --- /dev/null +++ b/test/integration/expression-tests/contains/value/test.json @@ -0,0 +1,40 @@ +{ + "expectExpressionType": null, + "expression": ["contains", ["get", "item"], ["array", ["get", "arr"]]], + "inputs": [ + [{}, {"properties": {"item": 3, "arr": [1, 2, 3, 4]}}], + [{}, {"properties": {"item": 5, "arr": [1, 2, 3, 4]}}], + [{}, {"properties": {"item": "3", "arr": [1, 2, 3, 4]}}], + [{}, {"properties": {"item": "a", "arr": ["a", "b", "c"]}}], + [{}, {"properties": {"item": "d", "arr": ["a", "b", "c"]}}], + [{}, {"properties": {"item": true, "arr": [true, true]}}], + [{}, {"properties": {"item": false, "arr": [true, true]}}], + [ + {}, + { + "properties": { + "item": ["nested", "array"], + "arr": [["nested", "array"]] + } + } + ] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [ + true, + false, + false, + true, + false, + true, + false, + {"error": "\"contains\" does not support values of type Array"} + ] + } +} diff --git a/test/integration/expression-tests/cos/basic/test.json b/test/integration/expression-tests/cos/basic/test.json new file mode 100644 index 00000000000..85f21d3cdc9 --- /dev/null +++ b/test/integration/expression-tests/cos/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["cos", 0], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [1] + } +} diff --git a/test/integration/expression-tests/curve/cubic-bezier-3-args/test.json b/test/integration/expression-tests/curve/cubic-bezier-3-args/test.json new file mode 100644 index 00000000000..3ea8c5af0b6 --- /dev/null +++ b/test/integration/expression-tests/curve/cubic-bezier-3-args/test.json @@ -0,0 +1,27 @@ +{ + "expectExpressionType": null, + "expression": [ + "number", + [ + "curve", + ["cubic-bezier", 0, 0, 1], + ["number", ["get", "x"]], + 0, + 0, + 100, + 100 + ] + ], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[1][1]", + "error": "Cubic bezier interpolation requires four numeric arguments with values between 0 and 1." + } + ] + } + } +} diff --git a/test/integration/expression-tests/curve/cubic-bezier-5-args/test.json b/test/integration/expression-tests/curve/cubic-bezier-5-args/test.json new file mode 100644 index 00000000000..ac09665d911 --- /dev/null +++ b/test/integration/expression-tests/curve/cubic-bezier-5-args/test.json @@ -0,0 +1,27 @@ +{ + "expectExpressionType": null, + "expression": [ + "number", + [ + "curve", + ["cubic-bezier", 0, 0, 1, 1, 1], + ["number", ["get", "x"]], + 0, + 0, + 100, + 100 + ] + ], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[1][1]", + "error": "Cubic bezier interpolation requires four numeric arguments with values between 0 and 1." + } + ] + } + } +} diff --git a/test/integration/expression-tests/curve/cubic-bezier-invalid-control-point/test.json b/test/integration/expression-tests/curve/cubic-bezier-invalid-control-point/test.json new file mode 100644 index 00000000000..356cccc866e --- /dev/null +++ b/test/integration/expression-tests/curve/cubic-bezier-invalid-control-point/test.json @@ -0,0 +1,27 @@ +{ + "expectExpressionType": null, + "expression": [ + "number", + [ + "curve", + ["cubic-bezier", 0, 1.75, 1, 1], + ["number", ["get", "x"]], + 0, + 0, + 100, + 100 + ] + ], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[1][1]", + "error": "Cubic bezier interpolation requires four numeric arguments with values between 0 and 1." + } + ] + } + } +} diff --git a/test/integration/expression-tests/curve/cubic-bezier/test.json b/test/integration/expression-tests/curve/cubic-bezier/test.json new file mode 100644 index 00000000000..2411252b528 --- /dev/null +++ b/test/integration/expression-tests/curve/cubic-bezier/test.json @@ -0,0 +1,49 @@ +{ + "expectExpressionType": null, + "expression": [ + "number", + [ + "curve", + ["cubic-bezier", 0.42, 0, 0.58, 1], + ["number", ["get", "x"]], + 0, + 0, + 100, + 100 + ] + ], + "inputs": [ + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": 10}}], + [{}, {"properties": {"x": 20}}], + [{}, {"properties": {"x": 30}}], + [{}, {"properties": {"x": 40}}], + [{}, {"properties": {"x": 50}}], + [{}, {"properties": {"x": 60}}], + [{}, {"properties": {"x": 70}}], + [{}, {"properties": {"x": 80}}], + [{}, {"properties": {"x": 90}}], + [{}, {"properties": {"x": 100}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [ + 0, + 1.972244726385564, + 8.165982204916352, + 18.739589115790917, + 33.18838697203919, + 50, + 66.81161302796082, + 81.26041088420908, + 91.83401779508367, + 98.02775527361445, + 100 + ] + } +} diff --git a/test/integration/expression-tests/curve/exponential-number-array/test.json b/test/integration/expression-tests/curve/exponential-number-array/test.json new file mode 100644 index 00000000000..8e148c04cef --- /dev/null +++ b/test/integration/expression-tests/curve/exponential-number-array/test.json @@ -0,0 +1,28 @@ +{ + "expectExpressionType": null, + "expression": [ + "curve", + ["exponential", 2], + ["number", ["get", "x"]], + 1, + ["literal", [2, 1]], + 3, + ["literal", [6, 1]] + ], + "inputs": [ + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": 2}}], + [{}, {"properties": {"x": 3}}], + [{}, {"properties": {"x": 4}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [[2, 1], [2, 1], [3.3333333333333335, 1], [6, 1], [6, 1]] + } +} diff --git a/test/integration/expression-tests/curve/exponential-single-stop/test.json b/test/integration/expression-tests/curve/exponential-single-stop/test.json new file mode 100644 index 00000000000..8b4f0d09975 --- /dev/null +++ b/test/integration/expression-tests/curve/exponential-single-stop/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": null, + "expression": [ + "number", + ["curve", ["exponential", 2], ["number", ["get", "x"]], 1, 2] + ], + "inputs": [ + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": 2}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [2, 2, 2] + } +} diff --git a/test/integration/expression-tests/curve/exponential-uninterpolatable-array/test.json b/test/integration/expression-tests/curve/exponential-uninterpolatable-array/test.json new file mode 100644 index 00000000000..5dddb25dea0 --- /dev/null +++ b/test/integration/expression-tests/curve/exponential-uninterpolatable-array/test.json @@ -0,0 +1,24 @@ +{ + "expectExpressionType": null, + "expression": [ + "curve", + ["exponential", 2], + ["number", ["get", "x"]], + 1, + ["literal", ["a"]], + 3, + ["literal", ["b"]] + ], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Type Array is not interpolatable, and thus cannot be used as a exponential curve's output type." + } + ] + } + } +} diff --git a/test/integration/expression-tests/curve/exponential/test.json b/test/integration/expression-tests/curve/exponential/test.json new file mode 100644 index 00000000000..e0b8c05c90a --- /dev/null +++ b/test/integration/expression-tests/curve/exponential/test.json @@ -0,0 +1,23 @@ +{ + "expectExpressionType": null, + "expression": [ + "number", + ["curve", ["exponential", 2], ["number", ["get", "x"]], 1, 2, 3, 6] + ], + "inputs": [ + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": 2}}], + [{}, {"properties": {"x": 3}}], + [{}, {"properties": {"x": 4}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [2, 2, 3.3333333333333335, 6, 6] + } +} diff --git a/test/integration/expression-tests/curve/infer-array-type/test.json b/test/integration/expression-tests/curve/infer-array-type/test.json new file mode 100644 index 00000000000..1c5b216e99f --- /dev/null +++ b/test/integration/expression-tests/curve/infer-array-type/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": {"kind": "Array", "itemType": {"kind": "String"}}, + "expression": [ + "curve", + ["step"], + ["number", ["get", "x"]], + ["literal", ["one"]], + 10, + ["literal", ["one", "two"]] + ], + "inputs": [], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [] + } +} diff --git a/test/integration/expression-tests/curve/linear-color/test.json b/test/integration/expression-tests/curve/linear-color/test.json new file mode 100644 index 00000000000..ff5d64220e4 --- /dev/null +++ b/test/integration/expression-tests/curve/linear-color/test.json @@ -0,0 +1,27 @@ +{ + "expectExpressionType": null, + "expression": [ + "to-rgba", + ["curve", ["exponential", 1], ["get", "x"], 1, "red", 11, ["get", "color"]] + ], + "inputs": [ + [{}, {"properties": {"x": 0, "color": "blue"}}], + [{}, {"properties": {"x": 5, "color": "blue"}}], + [{}, {"properties": {"x": 11, "color": "blue"}}], + [{}, {"properties": {"x": 11, "color": "oops blue"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [ + [1, 0, 0, 1], + [0.6, 0, 0.4, 1], + [0, 0, 1, 1], + {"error": "Could not parse color from value 'oops blue'"} + ] + } +} diff --git a/test/integration/expression-tests/curve/linear-many-stops/test.json b/test/integration/expression-tests/curve/linear-many-stops/test.json new file mode 100644 index 00000000000..bd249511b98 --- /dev/null +++ b/test/integration/expression-tests/curve/linear-many-stops/test.json @@ -0,0 +1,69 @@ +{ + "expectExpressionType": null, + "expression": [ + "number", + [ + "curve", + ["exponential", 1], + ["number", ["get", "x"]], + 2, + 100, + 55, + 200, + 132, + 300, + 607, + 400, + 1287, + 500, + 1985, + 600, + 2650, + 700, + 3299, + 800, + 3995, + 900, + 4927, + 1000, + 7147, + 10000, + 10028, + 100000, + 12889, + 1000000, + 40000, + 10000000 + ] + ], + "inputs": [ + [{}, {"properties": {"x": 2}}], + [{}, {"properties": {"x": 20}}], + [{}, {"properties": {"x": 607}}], + [{}, {"properties": {"x": 680}}], + [{}, {"properties": {"x": 4927}}], + [{}, {"properties": {"x": 7300}}], + [{}, {"properties": {"x": 10000}}], + [{}, {"properties": {"x": 20000}}], + [{}, {"properties": {"x": 40000}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [ + 100, + 133.9622641509434, + 400, + 410.7352941176471, + 1000, + 14779.590419993057, + 99125.30371398819, + 3360628.527166095, + 10000000 + ] + } +} diff --git a/test/integration/expression-tests/curve/linear/test.json b/test/integration/expression-tests/curve/linear/test.json new file mode 100644 index 00000000000..5e496e9c63c --- /dev/null +++ b/test/integration/expression-tests/curve/linear/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": {"kind": "Number"}, + "expression": ["curve", ["linear"], ["get", "x"], 0, 100, 10, 200], + "inputs": [ + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": 5}}], + [{}, {"properties": {"x": 10}}], + [{}, {"properties": {"x": -1234}}], + [{}, {"properties": {"x": 1234}}], + [{}, {"properties": {"x": "abcd"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [100, 150, 200, 100, 200, {"error": "Expected value to be of type Number, but found String instead."}] + } +} diff --git a/test/integration/expression-tests/curve/step/test.json b/test/integration/expression-tests/curve/step/test.json new file mode 100644 index 00000000000..e213ad48b54 --- /dev/null +++ b/test/integration/expression-tests/curve/step/test.json @@ -0,0 +1,24 @@ +{ + "expectExpressionType": null, + "expression": [ + "number", + ["curve", ["step"], ["number", ["get", "x"]], 11, 0, 111, 1, 1111] + ], + "inputs": [ + [{}, {"properties": {"x": -1.5}}], + [{}, {"properties": {"x": -0.5}}], + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": 0.5}}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": 1.5}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [11, 11, 111, 111, 1111, 1111] + } +} diff --git a/test/integration/expression-tests/divide/basic/test.json b/test/integration/expression-tests/divide/basic/test.json new file mode 100644 index 00000000000..5c36ab4ce7a --- /dev/null +++ b/test/integration/expression-tests/divide/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["/", 10, 5], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [2] + } +} diff --git a/test/integration/expression-tests/downcase/basic/test.json b/test/integration/expression-tests/downcase/basic/test.json new file mode 100644 index 00000000000..03c35c29ad1 --- /dev/null +++ b/test/integration/expression-tests/downcase/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["downcase", "StRiNg"], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "String" + }, + "outputs": ["string"] + } +} diff --git a/test/integration/expression-tests/e/basic/test.json b/test/integration/expression-tests/e/basic/test.json new file mode 100644 index 00000000000..c352ae76565 --- /dev/null +++ b/test/integration/expression-tests/e/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["e"], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [2.718281828459045] + } +} diff --git a/test/integration/expression-tests/equal/mismatch/test.json b/test/integration/expression-tests/equal/mismatch/test.json new file mode 100644 index 00000000000..d30b4d4226f --- /dev/null +++ b/test/integration/expression-tests/equal/mismatch/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["==", ["string", ["get", "x"]], ["number", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String) | (Boolean, Boolean) | (Null, Null), but found (String, Number) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/equal/number/test.json b/test/integration/expression-tests/equal/number/test.json new file mode 100644 index 00000000000..ce6c9fe2eb3 --- /dev/null +++ b/test/integration/expression-tests/equal/number/test.json @@ -0,0 +1,17 @@ +{ + "expectExpressionType": null, + "expression": ["==", ["number", ["get", "x"]], ["number", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": 1, "y": 1}}], + [{}, {"properties": {"x": 1, "y": 2}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [true, false] + } +} diff --git a/test/integration/expression-tests/equal/string/test.json b/test/integration/expression-tests/equal/string/test.json new file mode 100644 index 00000000000..04f1503ed82 --- /dev/null +++ b/test/integration/expression-tests/equal/string/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": null, + "expression": [ + "==", + ["to-string", ["get", "x"]], + ["to-string", ["get", "y"]] + ], + "inputs": [ + [{}, {"properties": {"x": 1, "y": 1}}], + [{}, {"properties": {"x": 1, "y": 2}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [true, false] + } +} diff --git a/test/integration/expression-tests/equal/untagged/test.json b/test/integration/expression-tests/equal/untagged/test.json new file mode 100644 index 00000000000..d43d6128fd6 --- /dev/null +++ b/test/integration/expression-tests/equal/untagged/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["==", ["string", ["get", "x"]], ["get", "y"]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String) | (Boolean, Boolean) | (Null, Null), but found (String, Value) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/geometry-type/basic/test.json b/test/integration/expression-tests/geometry-type/basic/test.json new file mode 100644 index 00000000000..2f8ae3a68a9 --- /dev/null +++ b/test/integration/expression-tests/geometry-type/basic/test.json @@ -0,0 +1,16 @@ +{ + "expectExpressionType": null, + "expression": ["geometry-type"], + "inputs": [ + [{}, {"geometry": {"type": "LineString", "coordinates": [[0, 0], [10, 0]]}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "String" + }, + "outputs": ["LineString"] + } +} diff --git a/test/integration/expression-tests/get/basic/test.json b/test/integration/expression-tests/get/basic/test.json new file mode 100644 index 00000000000..1ace9237068 --- /dev/null +++ b/test/integration/expression-tests/get/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["number", ["get", "x"]], + "inputs": [[{}, {}], [{}, {"properties": {"x": 1}}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [{"error": "Property 'x' not found in feature.properties"}, 1] + } +} diff --git a/test/integration/expression-tests/get/from-literal--missing/test.json b/test/integration/expression-tests/get/from-literal--missing/test.json new file mode 100644 index 00000000000..f6905e4ece4 --- /dev/null +++ b/test/integration/expression-tests/get/from-literal--missing/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["number", ["get", "x", ["literal", {"y": 0}]]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [{"error": "Property 'x' not found in object"}] + } +} diff --git a/test/integration/expression-tests/get/from-literal/test.json b/test/integration/expression-tests/get/from-literal/test.json new file mode 100644 index 00000000000..f52e2362d12 --- /dev/null +++ b/test/integration/expression-tests/get/from-literal/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["number", ["get", "x", ["literal", {"x": 0}]]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [0] + } +} diff --git a/test/integration/expression-tests/get/from-object-property/test.json b/test/integration/expression-tests/get/from-object-property/test.json new file mode 100644 index 00000000000..933ba1a04d0 --- /dev/null +++ b/test/integration/expression-tests/get/from-object-property/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["number", ["get", "x", ["object", ["get", "a"]]]], + "inputs": [[{}, {"properties": {"a": {"x": 1}}}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [1] + } +} diff --git a/test/integration/expression-tests/greater/boolean/test.json b/test/integration/expression-tests/greater/boolean/test.json new file mode 100644 index 00000000000..d2ace01f4c4 --- /dev/null +++ b/test/integration/expression-tests/greater/boolean/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [">", ["boolean", ["get", "x"]], ["boolean", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (Boolean, Boolean) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/greater/mismatch/test.json b/test/integration/expression-tests/greater/mismatch/test.json new file mode 100644 index 00000000000..dd121294110 --- /dev/null +++ b/test/integration/expression-tests/greater/mismatch/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [">", ["string", ["get", "x"]], ["number", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (String, Number) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/greater/null/test.json b/test/integration/expression-tests/greater/null/test.json new file mode 100644 index 00000000000..be8e37c3e6f --- /dev/null +++ b/test/integration/expression-tests/greater/null/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [">", null, null], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (Null, Null) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/greater/number/test.json b/test/integration/expression-tests/greater/number/test.json new file mode 100644 index 00000000000..412cdefdb40 --- /dev/null +++ b/test/integration/expression-tests/greater/number/test.json @@ -0,0 +1,18 @@ +{ + "expectExpressionType": null, + "expression": [">", ["number", ["get", "x"]], ["number", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": 1, "y": 1}}], + [{}, {"properties": {"x": 1, "y": 2}}], + [{}, {"properties": {"x": 2, "y": 1}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, false, true] + } +} diff --git a/test/integration/expression-tests/greater/string/test.json b/test/integration/expression-tests/greater/string/test.json new file mode 100644 index 00000000000..ef09652c615 --- /dev/null +++ b/test/integration/expression-tests/greater/string/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": null, + "expression": [">", ["string", ["get", "x"]], ["string", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": "1", "y": "1"}}], + [{}, {"properties": {"x": "1", "y": "2"}}], + [{}, {"properties": {"x": "2", "y": "1"}}], + [{}, {"properties": {"x": "abc", "y": "azz"}}], + [{}, {"properties": {"x": "abc", "y": "aaa"}}], + [{}, {"properties": {"x": "abc", "y": "abc"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, false, true, false, true, false] + } +} diff --git a/test/integration/expression-tests/greater/value/test.json b/test/integration/expression-tests/greater/value/test.json new file mode 100644 index 00000000000..66ef883443a --- /dev/null +++ b/test/integration/expression-tests/greater/value/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [">", ["string", ["get", "x"]], ["get", "y"]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (String, Value) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/greater_or_equal/boolean/test.json b/test/integration/expression-tests/greater_or_equal/boolean/test.json new file mode 100644 index 00000000000..633c2a05d07 --- /dev/null +++ b/test/integration/expression-tests/greater_or_equal/boolean/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [">=", ["boolean", ["get", "x"]], ["boolean", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (Boolean, Boolean) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/greater_or_equal/mismatch/test.json b/test/integration/expression-tests/greater_or_equal/mismatch/test.json new file mode 100644 index 00000000000..d43ae47acd5 --- /dev/null +++ b/test/integration/expression-tests/greater_or_equal/mismatch/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [">=", ["string", ["get", "x"]], ["number", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (String, Number) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/greater_or_equal/null/test.json b/test/integration/expression-tests/greater_or_equal/null/test.json new file mode 100644 index 00000000000..7f04b8c5017 --- /dev/null +++ b/test/integration/expression-tests/greater_or_equal/null/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [">=", null, null], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (Null, Null) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/greater_or_equal/number/test.json b/test/integration/expression-tests/greater_or_equal/number/test.json new file mode 100644 index 00000000000..98ed8f91c65 --- /dev/null +++ b/test/integration/expression-tests/greater_or_equal/number/test.json @@ -0,0 +1,18 @@ +{ + "expectExpressionType": null, + "expression": [">=", ["number", ["get", "x"]], ["number", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": 1, "y": 1}}], + [{}, {"properties": {"x": 1, "y": 2}}], + [{}, {"properties": {"x": 2, "y": 1}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [true, false, true] + } +} diff --git a/test/integration/expression-tests/greater_or_equal/string/test.json b/test/integration/expression-tests/greater_or_equal/string/test.json new file mode 100644 index 00000000000..589d3d61a5e --- /dev/null +++ b/test/integration/expression-tests/greater_or_equal/string/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": null, + "expression": [">=", ["string", ["get", "x"]], ["string", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": "1", "y": "1"}}], + [{}, {"properties": {"x": "1", "y": "2"}}], + [{}, {"properties": {"x": "2", "y": "1"}}], + [{}, {"properties": {"x": "abc", "y": "azz"}}], + [{}, {"properties": {"x": "abc", "y": "aaa"}}], + [{}, {"properties": {"x": "abc", "y": "abc"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [true, false, true, false, true, true] + } +} diff --git a/test/integration/expression-tests/greater_or_equal/value/test.json b/test/integration/expression-tests/greater_or_equal/value/test.json new file mode 100644 index 00000000000..125d30eafdd --- /dev/null +++ b/test/integration/expression-tests/greater_or_equal/value/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [">=", ["string", ["get", "x"]], ["get", "y"]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (String, Value) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/has/basic/test.json b/test/integration/expression-tests/has/basic/test.json new file mode 100644 index 00000000000..54c8ac41676 --- /dev/null +++ b/test/integration/expression-tests/has/basic/test.json @@ -0,0 +1,20 @@ +{ + "expectExpressionType": null, + "expression": ["has", "x"], + "inputs": [ + [{}, {}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": null}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, true, true, true, true] + } +} diff --git a/test/integration/expression-tests/id/basic/test.json b/test/integration/expression-tests/id/basic/test.json new file mode 100644 index 00000000000..099c1d27f52 --- /dev/null +++ b/test/integration/expression-tests/id/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["id"], + "inputs": [[{}, {}], [{}, {"id": 1}], [{}, {"id": "one"}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Value" + }, + "outputs": [null, 1, "one"] + } +} diff --git a/test/integration/expression-tests/length/array/test.json b/test/integration/expression-tests/length/array/test.json new file mode 100644 index 00000000000..8d6b5807a1b --- /dev/null +++ b/test/integration/expression-tests/length/array/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["length", ["array", ["get", "x"]]], + "inputs": [[{}, {"properties": {"x": [1, 2, 3, 4, 5]}}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [5] + } +} diff --git a/test/integration/expression-tests/length/string/test.json b/test/integration/expression-tests/length/string/test.json new file mode 100644 index 00000000000..d60e46fff2f --- /dev/null +++ b/test/integration/expression-tests/length/string/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["length", ["string", ["get", "x"]]], + "inputs": [[{}, {"properties": {"x": "a string"}}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [8] + } +} diff --git a/test/integration/expression-tests/less/boolean/test.json b/test/integration/expression-tests/less/boolean/test.json new file mode 100644 index 00000000000..581e2c34e31 --- /dev/null +++ b/test/integration/expression-tests/less/boolean/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["<", ["boolean", ["get", "x"]], ["boolean", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (Boolean, Boolean) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/less/mismatch/test.json b/test/integration/expression-tests/less/mismatch/test.json new file mode 100644 index 00000000000..4d231d359a7 --- /dev/null +++ b/test/integration/expression-tests/less/mismatch/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["<", ["string", ["get", "x"]], ["number", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (String, Number) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/less/null/test.json b/test/integration/expression-tests/less/null/test.json new file mode 100644 index 00000000000..e48b81edbbb --- /dev/null +++ b/test/integration/expression-tests/less/null/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["<", null, null], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (Null, Null) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/less/number/test.json b/test/integration/expression-tests/less/number/test.json new file mode 100644 index 00000000000..7661e93bee5 --- /dev/null +++ b/test/integration/expression-tests/less/number/test.json @@ -0,0 +1,18 @@ +{ + "expectExpressionType": null, + "expression": ["<", ["number", ["get", "x"]], ["number", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": 1, "y": 1}}], + [{}, {"properties": {"x": 1, "y": 2}}], + [{}, {"properties": {"x": 2, "y": 1}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, true, false] + } +} diff --git a/test/integration/expression-tests/less/string/test.json b/test/integration/expression-tests/less/string/test.json new file mode 100644 index 00000000000..8ea13aaf8d4 --- /dev/null +++ b/test/integration/expression-tests/less/string/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": null, + "expression": ["<", ["string", ["get", "x"]], ["string", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": "1", "y": "1"}}], + [{}, {"properties": {"x": "1", "y": "2"}}], + [{}, {"properties": {"x": "2", "y": "1"}}], + [{}, {"properties": {"x": "abc", "y": "azz"}}], + [{}, {"properties": {"x": "abc", "y": "aaa"}}], + [{}, {"properties": {"x": "abc", "y": "abc"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, true, false, true, false, false] + } +} diff --git a/test/integration/expression-tests/less/value/test.json b/test/integration/expression-tests/less/value/test.json new file mode 100644 index 00000000000..19bad29787e --- /dev/null +++ b/test/integration/expression-tests/less/value/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["<", ["string", ["get", "x"]], ["get", "y"]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (String, Value) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/less_or_equal/boolean/test.json b/test/integration/expression-tests/less_or_equal/boolean/test.json new file mode 100644 index 00000000000..55e90512077 --- /dev/null +++ b/test/integration/expression-tests/less_or_equal/boolean/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["<=", ["boolean", ["get", "x"]], ["boolean", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (Boolean, Boolean) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/less_or_equal/mismatch/test.json b/test/integration/expression-tests/less_or_equal/mismatch/test.json new file mode 100644 index 00000000000..c45d14636b5 --- /dev/null +++ b/test/integration/expression-tests/less_or_equal/mismatch/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["<=", ["string", ["get", "x"]], ["number", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (String, Number) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/less_or_equal/null/test.json b/test/integration/expression-tests/less_or_equal/null/test.json new file mode 100644 index 00000000000..9f04c547087 --- /dev/null +++ b/test/integration/expression-tests/less_or_equal/null/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["<=", null, null], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (Null, Null) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/less_or_equal/number/test.json b/test/integration/expression-tests/less_or_equal/number/test.json new file mode 100644 index 00000000000..baf688147b0 --- /dev/null +++ b/test/integration/expression-tests/less_or_equal/number/test.json @@ -0,0 +1,18 @@ +{ + "expectExpressionType": null, + "expression": ["<=", ["number", ["get", "x"]], ["number", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": 1, "y": 1}}], + [{}, {"properties": {"x": 1, "y": 2}}], + [{}, {"properties": {"x": 2, "y": 1}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [true, true, false] + } +} diff --git a/test/integration/expression-tests/less_or_equal/string/test.json b/test/integration/expression-tests/less_or_equal/string/test.json new file mode 100644 index 00000000000..75ea2dcedfe --- /dev/null +++ b/test/integration/expression-tests/less_or_equal/string/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": null, + "expression": ["<=", ["string", ["get", "x"]], ["string", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": "1", "y": "1"}}], + [{}, {"properties": {"x": "1", "y": "2"}}], + [{}, {"properties": {"x": "2", "y": "1"}}], + [{}, {"properties": {"x": "abc", "y": "azz"}}], + [{}, {"properties": {"x": "abc", "y": "aaa"}}], + [{}, {"properties": {"x": "abc", "y": "abc"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [true, true, false, true, false, true] + } +} diff --git a/test/integration/expression-tests/less_or_equal/value/test.json b/test/integration/expression-tests/less_or_equal/value/test.json new file mode 100644 index 00000000000..ced5a657e5a --- /dev/null +++ b/test/integration/expression-tests/less_or_equal/value/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["<=", ["string", ["get", "x"]], ["get", "y"]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String), but found (String, Value) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/let/basic/test.json b/test/integration/expression-tests/let/basic/test.json new file mode 100644 index 00000000000..527a658d80c --- /dev/null +++ b/test/integration/expression-tests/let/basic/test.json @@ -0,0 +1,21 @@ +{ + "expectExpressionType": null, + "expression": [ + "let", + "a", + 1, + "b", + 2, + ["+", ["+", ["var", "a"], ["var", "b"]], ["var", "a"]] + ], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [4] + } +} diff --git a/test/integration/expression-tests/let/invalid-name/test.json b/test/integration/expression-tests/let/invalid-name/test.json new file mode 100644 index 00000000000..50d0ac70ea1 --- /dev/null +++ b/test/integration/expression-tests/let/invalid-name/test.json @@ -0,0 +1,16 @@ +{ + "expectExpressionType": null, + "expression": ["let", "$a", 1, ["var", "$a"]], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[1]", + "error": "Variable names must contain only alphanumeric characters or '_'." + } + ] + } + } +} diff --git a/test/integration/expression-tests/let/nested/test.json b/test/integration/expression-tests/let/nested/test.json new file mode 100644 index 00000000000..c1f6cf5b5e2 --- /dev/null +++ b/test/integration/expression-tests/let/nested/test.json @@ -0,0 +1,19 @@ +{ + "expectExpressionType": null, + "expression": [ + "let", + "a", + 1, + ["let", "b", ["+", 1, ["var", "a"]], ["+", ["var", "a"], ["var", "b"]]] + ], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [3] + } +} diff --git a/test/integration/expression-tests/let/property-function/test.json b/test/integration/expression-tests/let/property-function/test.json new file mode 100644 index 00000000000..9632f4f2388 --- /dev/null +++ b/test/integration/expression-tests/let/property-function/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["let", "a", ["get", "x"], ["+", 1, ["number", ["var", "a"]]]], + "inputs": [[{}, {"properties": {"x": 5}}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [6] + } +} diff --git a/test/integration/expression-tests/let/shadow/test.json b/test/integration/expression-tests/let/shadow/test.json new file mode 100644 index 00000000000..25b75cc4bd4 --- /dev/null +++ b/test/integration/expression-tests/let/shadow/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["let", "a", 1, ["let", "a", 2, ["var", "a"]]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [2] + } +} diff --git a/test/integration/expression-tests/let/unbound/test.json b/test/integration/expression-tests/let/unbound/test.json new file mode 100644 index 00000000000..957a9244cfd --- /dev/null +++ b/test/integration/expression-tests/let/unbound/test.json @@ -0,0 +1,20 @@ +{ + "expectExpressionType": null, + "expression": [ + "let", + "a", + 1, + ["+", ["+", ["var", "a"], ["var", "b"]], ["var", "a"]] + ], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[3][1][2][1]", + "error": "Unknown variable \"b\". Make sure \"b\" has been bound in an enclosing \"let\" expression before using it." + } + ] + } + } +} diff --git a/test/integration/expression-tests/let/zoom/test.json b/test/integration/expression-tests/let/zoom/test.json new file mode 100644 index 00000000000..4d4cc046dcc --- /dev/null +++ b/test/integration/expression-tests/let/zoom/test.json @@ -0,0 +1,29 @@ +{ + "expectExpressionType": null, + "expression": [ + "let", + "z0_value", + 10, + "z20_value", + 30, + [ + "curve", + ["linear"], + ["zoom"], + 0, + ["var", "z0_value"], + 20, + ["var", "z20_value"] + ] + ], + "inputs": [[{"zoom": 10}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": false, + "type": "Number" + }, + "outputs": [20] + } +} diff --git a/test/integration/expression-tests/literal/boolean-array/test.json b/test/integration/expression-tests/literal/boolean-array/test.json new file mode 100644 index 00000000000..dec7f6aa7e9 --- /dev/null +++ b/test/integration/expression-tests/literal/boolean-array/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["literal", [true, false]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [[true, false]] + } +} diff --git a/test/integration/expression-tests/literal/empty/test.json b/test/integration/expression-tests/literal/empty/test.json new file mode 100644 index 00000000000..ec18e074c1e --- /dev/null +++ b/test/integration/expression-tests/literal/empty/test.json @@ -0,0 +1,16 @@ +{ + "expectExpressionType": null, + "expression": ["literal"], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "'literal' expression requires exactly one argument, but found 0 instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/literal/infer-empty-array-type/test.json b/test/integration/expression-tests/literal/infer-empty-array-type/test.json new file mode 100644 index 00000000000..d7da867b62f --- /dev/null +++ b/test/integration/expression-tests/literal/infer-empty-array-type/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": {"kind": "Array", "itemType": {"kind": "Number"}}, + "expression": ["literal", []], + "inputs": [], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [] + } +} diff --git a/test/integration/expression-tests/literal/mixed-primitive-array/test.json b/test/integration/expression-tests/literal/mixed-primitive-array/test.json new file mode 100644 index 00000000000..beed855244d --- /dev/null +++ b/test/integration/expression-tests/literal/mixed-primitive-array/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["literal", [1, "2"]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [[1, "2"]] + } +} diff --git a/test/integration/expression-tests/literal/multiple-args/test.json b/test/integration/expression-tests/literal/multiple-args/test.json new file mode 100644 index 00000000000..ea4a58d3c90 --- /dev/null +++ b/test/integration/expression-tests/literal/multiple-args/test.json @@ -0,0 +1,16 @@ +{ + "expectExpressionType": null, + "expression": ["literal", {}, []], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "'literal' expression requires exactly one argument, but found 2 instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/literal/nested-array/test.json b/test/integration/expression-tests/literal/nested-array/test.json new file mode 100644 index 00000000000..bca208cc75f --- /dev/null +++ b/test/integration/expression-tests/literal/nested-array/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["literal", [1, [3, 4]]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [[1, [3, 4]]] + } +} diff --git a/test/integration/expression-tests/literal/number-array/test.json b/test/integration/expression-tests/literal/number-array/test.json new file mode 100644 index 00000000000..0871abf7938 --- /dev/null +++ b/test/integration/expression-tests/literal/number-array/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["literal", [1, 2]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [[1, 2]] + } +} diff --git a/test/integration/expression-tests/literal/object/test.json b/test/integration/expression-tests/literal/object/test.json new file mode 100644 index 00000000000..0b3f95d650e --- /dev/null +++ b/test/integration/expression-tests/literal/object/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["literal", {"x": 1}], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Object" + }, + "outputs": [{"x": 1}] + } +} diff --git a/test/integration/expression-tests/literal/string-array/test.json b/test/integration/expression-tests/literal/string-array/test.json new file mode 100644 index 00000000000..c43e4860f5a --- /dev/null +++ b/test/integration/expression-tests/literal/string-array/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["literal", ["1", "2"]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [["1", "2"]] + } +} diff --git a/test/integration/expression-tests/literal/string/test.json b/test/integration/expression-tests/literal/string/test.json new file mode 100644 index 00000000000..d5edeaa2e1c --- /dev/null +++ b/test/integration/expression-tests/literal/string/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": "ahoy!", + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "String" + }, + "outputs": ["ahoy!"] + } +} diff --git a/test/integration/expression-tests/ln/basic/test.json b/test/integration/expression-tests/ln/basic/test.json new file mode 100644 index 00000000000..34a1a24da69 --- /dev/null +++ b/test/integration/expression-tests/ln/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["ln", ["e"]], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [1] + } +} diff --git a/test/integration/expression-tests/ln2/basic/test.json b/test/integration/expression-tests/ln2/basic/test.json new file mode 100644 index 00000000000..0ee7f7894c6 --- /dev/null +++ b/test/integration/expression-tests/ln2/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["ln2"], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [0.6931471805599453] + } +} diff --git a/test/integration/expression-tests/log10/basic/test.json b/test/integration/expression-tests/log10/basic/test.json new file mode 100644 index 00000000000..6dfbaa2f9ca --- /dev/null +++ b/test/integration/expression-tests/log10/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["log10", 100], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [2] + } +} diff --git a/test/integration/expression-tests/log2/basic/test.json b/test/integration/expression-tests/log2/basic/test.json new file mode 100644 index 00000000000..2daa86d0e56 --- /dev/null +++ b/test/integration/expression-tests/log2/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["log2", 1024], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [10] + } +} diff --git a/test/integration/expression-tests/match/arity-0/test.json b/test/integration/expression-tests/match/arity-0/test.json new file mode 100644 index 00000000000..d5e12ea26c9 --- /dev/null +++ b/test/integration/expression-tests/match/arity-0/test.json @@ -0,0 +1,12 @@ +{ + "expectExpressionType": null, + "expression": ["match"], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "", "error": "Expected at least 4 arguments, but found only 0."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/arity-1/test.json b/test/integration/expression-tests/match/arity-1/test.json new file mode 100644 index 00000000000..00522a0be21 --- /dev/null +++ b/test/integration/expression-tests/match/arity-1/test.json @@ -0,0 +1,12 @@ +{ + "expectExpressionType": null, + "expression": ["match", "x"], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "", "error": "Expected at least 4 arguments, but found only 1."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/arity-2/test.json b/test/integration/expression-tests/match/arity-2/test.json new file mode 100644 index 00000000000..fbb35ed7f69 --- /dev/null +++ b/test/integration/expression-tests/match/arity-2/test.json @@ -0,0 +1,13 @@ +{ + "expectExpressionType": null, + "expression": ["match", "x", "y"], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "", "error": "Expected at least 4 arguments, but found only 2."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/arity-3/test.json b/test/integration/expression-tests/match/arity-3/test.json new file mode 100644 index 00000000000..831f429b104 --- /dev/null +++ b/test/integration/expression-tests/match/arity-3/test.json @@ -0,0 +1,13 @@ +{ + "expectExpressionType": null, + "expression": ["match", "x", "y", "z"], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "", "error": "Expected at least 4 arguments, but found only 3."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/basic/test.json b/test/integration/expression-tests/match/basic/test.json new file mode 100644 index 00000000000..373d7447fcf --- /dev/null +++ b/test/integration/expression-tests/match/basic/test.json @@ -0,0 +1,19 @@ +{ + "expectExpressionType": null, + "expression": ["match", ["get", "x"], "a", "Apple", "b", "Banana", "Kumquat"], + "inputs": [ + [{}, {"properties": {"x": "a"}}], + [{}, {"properties": {"x": "b"}}], + [{}, {"properties": {"x": "c"}}], + [{}, {"properties": {"x": 0}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "String" + }, + "outputs": ["Apple", "Banana", "Kumquat", { "error": "Expected value to be of type String, but found Number instead." }] + } +} diff --git a/test/integration/expression-tests/match/empty-case/test.json b/test/integration/expression-tests/match/empty-case/test.json new file mode 100644 index 00000000000..50741d70b64 --- /dev/null +++ b/test/integration/expression-tests/match/empty-case/test.json @@ -0,0 +1,10 @@ +{ + "expectExpressionType": null, + "expression": ["match", ["get", "x"], [], "thing one", "thing two"], + "expected": { + "compiled": { + "result": "error", + "errors": [{"key": "[2]", "error": "Expected at least one branch label."}] + } + } +} diff --git a/test/integration/expression-tests/match/infer-array-type/test.json b/test/integration/expression-tests/match/infer-array-type/test.json new file mode 100644 index 00000000000..e20d366bda1 --- /dev/null +++ b/test/integration/expression-tests/match/infer-array-type/test.json @@ -0,0 +1,22 @@ +{ + "expectExpressionType": {"kind": "Array", "itemType": {"kind": "String"}}, + "expression": [ + "match", + ["number", ["get", "x"]], + 0, + ["literal", ["one"]], + 10, + ["literal", ["one", "two"]], + ["literal", ["one", "two", "three"]] + ], + "inputs": [], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [] + } +} diff --git a/test/integration/expression-tests/match/label-array/test.json b/test/integration/expression-tests/match/label-array/test.json new file mode 100644 index 00000000000..e20b0444066 --- /dev/null +++ b/test/integration/expression-tests/match/label-array/test.json @@ -0,0 +1,18 @@ +{ + "expectExpressionType": null, + "expression": [ + "match", + "x", + ["string", ["get", "y"]], + "thing one", + "thing two" + ], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[2]", "error": "Branch labels must be numbers or strings."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/label-boolean/test.json b/test/integration/expression-tests/match/label-boolean/test.json new file mode 100644 index 00000000000..731a7a2fba7 --- /dev/null +++ b/test/integration/expression-tests/match/label-boolean/test.json @@ -0,0 +1,26 @@ +{ + "expectExpressionType": null, + "expression": [ + "match", + ["boolean", ["get", "x"]], + true, + "match", + "otherwise" + ], + "inputs": [ + [{}, {"properties": {"x": true}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": "true"}}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": null}}], + [{}, {"properties": {}}] + ], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[2]", "error": "Branch labels must be numbers or strings."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/label-non-integer/test.json b/test/integration/expression-tests/match/label-non-integer/test.json new file mode 100644 index 00000000000..53449dac6da --- /dev/null +++ b/test/integration/expression-tests/match/label-non-integer/test.json @@ -0,0 +1,12 @@ +{ + "expectExpressionType": null, + "expression": ["match", 1, 1.5, "thing one", "thing two"], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[2]", "error": "Numeric branch labels must be integer values."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/label-null/test.json b/test/integration/expression-tests/match/label-null/test.json new file mode 100644 index 00000000000..55e4266bce7 --- /dev/null +++ b/test/integration/expression-tests/match/label-null/test.json @@ -0,0 +1,13 @@ +{ + "expectExpressionType": null, + "expression": ["match", null, null, "match", "otherwise"], + "inputs": [[{}, {"properties": {}}]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[2]", "error": "Branch labels must be numbers or strings."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/label-number/test.json b/test/integration/expression-tests/match/label-number/test.json new file mode 100644 index 00000000000..20cb10c9f01 --- /dev/null +++ b/test/integration/expression-tests/match/label-number/test.json @@ -0,0 +1,34 @@ +{ + "expectExpressionType": null, + "expression": ["match", ["get", "x"], 0, "match", "otherwise"], + "inputs": [ + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": 0.5}}], + [{}, {"properties": {"x": "0"}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": null}}], + [{}, {"properties": {}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "String" + }, + "outputs": [ + "match", + "otherwise", + "otherwise", + { + "error": "Expected value to be of type Number, but found String instead." + }, + { + "error": "Expected value to be of type Number, but found Boolean instead." + }, + {"error": "Expected value to be of type Number, but found Null instead."}, + {"error": "Property 'x' not found in feature.properties"} + ] + } +} diff --git a/test/integration/expression-tests/match/label-object/test.json b/test/integration/expression-tests/match/label-object/test.json new file mode 100644 index 00000000000..473140db43e --- /dev/null +++ b/test/integration/expression-tests/match/label-object/test.json @@ -0,0 +1,12 @@ +{ + "expectExpressionType": null, + "expression": ["match", "x", {}, "thing one", "thing two"], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[2]", "error": "Branch labels must be numbers or strings."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/label-overflow/test.json b/test/integration/expression-tests/match/label-overflow/test.json new file mode 100644 index 00000000000..a5940c353f7 --- /dev/null +++ b/test/integration/expression-tests/match/label-overflow/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["match", 0, 10000000000000000, "thing one", "thing two"], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[2]", + "error": "Branch labels must be integers no larger than 9007199254740991." + } + ] + } + } +} diff --git a/test/integration/expression-tests/match/label-string/test.json b/test/integration/expression-tests/match/label-string/test.json new file mode 100644 index 00000000000..2fe89b4b628 --- /dev/null +++ b/test/integration/expression-tests/match/label-string/test.json @@ -0,0 +1,32 @@ +{ + "expectExpressionType": null, + "expression": ["match", ["string", ["get", "x"]], "0", "match", "otherwise"], + "inputs": [ + [{}, {"properties": {"x": "0"}}], + [{}, {"properties": {"x": "1"}}], + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": null}}], + [{}, {"properties": {}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "String" + }, + "outputs": [ + "match", + "otherwise", + { + "error": "Expected value to be of type String, but found Number instead." + }, + { + "error": "Expected value to be of type String, but found Boolean instead." + }, + {"error": "Expected value to be of type String, but found Null instead."}, + {"error": "Property 'x' not found in feature.properties"} + ] + } +} diff --git a/test/integration/expression-tests/match/mismatch-input/test.json b/test/integration/expression-tests/match/mismatch-input/test.json new file mode 100644 index 00000000000..59956c417d3 --- /dev/null +++ b/test/integration/expression-tests/match/mismatch-input/test.json @@ -0,0 +1,12 @@ +{ + "expectExpressionType": null, + "expression": ["match", ["string", ["get", "x"]], 0, "match", "otherwise"], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[1]", "error": "Expected Number but found String instead."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/mismatch-label-1/test.json b/test/integration/expression-tests/match/mismatch-label-1/test.json new file mode 100644 index 00000000000..8908fa8d31c --- /dev/null +++ b/test/integration/expression-tests/match/mismatch-label-1/test.json @@ -0,0 +1,22 @@ +{ + "expectExpressionType": null, + "expression": [ + "match", + ["get", "x"], + "a", + "the letter a", + 0, + "the number 0", + true, + "The Truth", + "otherwise" + ], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[4]", "error": "Expected String but found Number instead."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/mismatch-label-2/test.json b/test/integration/expression-tests/match/mismatch-label-2/test.json new file mode 100644 index 00000000000..a5d70bd6a56 --- /dev/null +++ b/test/integration/expression-tests/match/mismatch-label-2/test.json @@ -0,0 +1,12 @@ +{ + "expectExpressionType": null, + "expression": ["match", ["get", "x"], ["0", 0], "zero", "otherwise"], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[2]", "error": "Expected String but found Number instead."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/mismatch-output/test.json b/test/integration/expression-tests/match/mismatch-output/test.json new file mode 100644 index 00000000000..3b1348c5afb --- /dev/null +++ b/test/integration/expression-tests/match/mismatch-output/test.json @@ -0,0 +1,12 @@ +{ + "expectExpressionType": null, + "expression": ["match", ["string", ["get", "x"]], "0", "match", false], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[4]", "error": "Expected String but found Boolean instead."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/mixed-type/test.json b/test/integration/expression-tests/match/mixed-type/test.json new file mode 100644 index 00000000000..7d96714378b --- /dev/null +++ b/test/integration/expression-tests/match/mixed-type/test.json @@ -0,0 +1,28 @@ +{ + "expectExpressionType": null, + "expression": [ + "match", + ["get", "x"], + "a", + "the letter a", + 0, + "the number 0", + true, + "The Truth", + "otherwise" + ], + "inputs": [ + [{}, {"properties": {"x": "a"}}], + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": "0"}}], + [{}, {"properties": {"x": true}}] + ], + "expected": { + "compiled": { + "result": "error", + "errors": [ + {"key": "[4]", "error": "Expected String but found Number instead."} + ] + } + } +} diff --git a/test/integration/expression-tests/match/multi-value/test.json b/test/integration/expression-tests/match/multi-value/test.json new file mode 100644 index 00000000000..e5612a64f93 --- /dev/null +++ b/test/integration/expression-tests/match/multi-value/test.json @@ -0,0 +1,31 @@ +{ + "expectExpressionType": null, + "expression": [ + "string", + [ + "match", + ["string", ["get", "x"]], + ["a", "A"], + "Apple", + ["b", "B"], + "Banana", + "Kumquat" + ] + ], + "inputs": [ + [{}, {"properties": {"x": "a"}}], + [{}, {"properties": {"x": "A"}}], + [{}, {"properties": {"x": "b"}}], + [{}, {"properties": {"x": "B"}}], + [{}, {"properties": {"x": "c"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "String" + }, + "outputs": ["Apple", "Apple", "Banana", "Banana", "Kumquat"] + } +} diff --git a/test/integration/expression-tests/match/unreachable-branch-1/test.json b/test/integration/expression-tests/match/unreachable-branch-1/test.json new file mode 100644 index 00000000000..1e6284f49ee --- /dev/null +++ b/test/integration/expression-tests/match/unreachable-branch-1/test.json @@ -0,0 +1,18 @@ +{ + "expectExpressionType": null, + "expression": [ + "match", + ["string", ["get", "x"]], + "0", + "match", + "0", + "match", + "otherwise" + ], + "expected": { + "compiled": { + "result": "error", + "errors": [{"key": "[4]", "error": "Branch labels must be unique."}] + } + } +} diff --git a/test/integration/expression-tests/match/unreachable-branch-2/test.json b/test/integration/expression-tests/match/unreachable-branch-2/test.json new file mode 100644 index 00000000000..b6676981213 --- /dev/null +++ b/test/integration/expression-tests/match/unreachable-branch-2/test.json @@ -0,0 +1,18 @@ +{ + "expectExpressionType": null, + "expression": [ + "match", + ["string", ["get", "x"]], + ["0", "1"], + "match", + ["0", "2"], + "match", + "otherwise" + ], + "expected": { + "compiled": { + "result": "error", + "errors": [{"key": "[4]", "error": "Branch labels must be unique."}] + } + } +} diff --git a/test/integration/expression-tests/max/basic/test.json b/test/integration/expression-tests/max/basic/test.json new file mode 100644 index 00000000000..48918d90fed --- /dev/null +++ b/test/integration/expression-tests/max/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["max", 0, -1, 100], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [100] + } +} diff --git a/test/integration/expression-tests/min/basic/test.json b/test/integration/expression-tests/min/basic/test.json new file mode 100644 index 00000000000..b0d85c59763 --- /dev/null +++ b/test/integration/expression-tests/min/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["min", 0, -1, 10], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [-1] + } +} diff --git a/test/integration/expression-tests/minus/basic/test.json b/test/integration/expression-tests/minus/basic/test.json new file mode 100644 index 00000000000..3455ed2e4ea --- /dev/null +++ b/test/integration/expression-tests/minus/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["-", 5, 7], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [-2] + } +} diff --git a/test/integration/expression-tests/minus/unary/test.json b/test/integration/expression-tests/minus/unary/test.json new file mode 100644 index 00000000000..e724bf6cb48 --- /dev/null +++ b/test/integration/expression-tests/minus/unary/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["-", 5], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [-5] + } +} diff --git a/test/integration/expression-tests/mod/basic/test.json b/test/integration/expression-tests/mod/basic/test.json new file mode 100644 index 00000000000..0b5d558a4c7 --- /dev/null +++ b/test/integration/expression-tests/mod/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["%", 18, 12], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [6] + } +} diff --git a/test/integration/expression-tests/not/basic/test.json b/test/integration/expression-tests/not/basic/test.json new file mode 100644 index 00000000000..948c6328e97 --- /dev/null +++ b/test/integration/expression-tests/not/basic/test.json @@ -0,0 +1,17 @@ +{ + "expectExpressionType": null, + "expression": ["!", ["boolean", ["get", "x"]]], + "inputs": [ + [{}, {"properties": {"x": true}}], + [{}, {"properties": {"x": false}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, true] + } +} diff --git a/test/integration/expression-tests/not_equal/mismatch/test.json b/test/integration/expression-tests/not_equal/mismatch/test.json new file mode 100644 index 00000000000..59e69cfad28 --- /dev/null +++ b/test/integration/expression-tests/not_equal/mismatch/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["!=", ["string", ["get", "x"]], ["number", ["get", "y"]]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String) | (Boolean, Boolean) | (Null, Null), but found (String, Number) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/not_equal/number/test.json b/test/integration/expression-tests/not_equal/number/test.json new file mode 100644 index 00000000000..2b175d6bc4e --- /dev/null +++ b/test/integration/expression-tests/not_equal/number/test.json @@ -0,0 +1,17 @@ +{ + "expectExpressionType": null, + "expression": ["!=", ["number", ["get", "x"]], ["number", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": 1, "y": 1}}], + [{}, {"properties": {"x": 1, "y": 2}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, true] + } +} diff --git a/test/integration/expression-tests/not_equal/string/test.json b/test/integration/expression-tests/not_equal/string/test.json new file mode 100644 index 00000000000..0be17eb9968 --- /dev/null +++ b/test/integration/expression-tests/not_equal/string/test.json @@ -0,0 +1,17 @@ +{ + "expectExpressionType": null, + "expression": ["!=", ["string", ["get", "x"]], ["string", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": "1", "y": "1"}}], + [{}, {"properties": {"x": "1", "y": "2"}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [false, true] + } +} diff --git a/test/integration/expression-tests/not_equal/untagged/test.json b/test/integration/expression-tests/not_equal/untagged/test.json new file mode 100644 index 00000000000..4c7585ceb06 --- /dev/null +++ b/test/integration/expression-tests/not_equal/untagged/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["!=", ["string", ["get", "x"]], ["get", "y"]], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected arguments of type (Number, Number) | (String, String) | (Boolean, Boolean) | (Null, Null), but found (String, Value) instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/number/basic/test.json b/test/integration/expression-tests/number/basic/test.json new file mode 100644 index 00000000000..285dab4974b --- /dev/null +++ b/test/integration/expression-tests/number/basic/test.json @@ -0,0 +1,28 @@ +{ + "expectExpressionType": null, + "expression": ["number", ["get", "x"]], + "inputs": [ + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": "1"}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": null}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [ + 1, + { + "error": "Expected value to be of type Number, but found String instead." + }, + { + "error": "Expected value to be of type Number, but found Boolean instead." + }, + {"error": "Expected value to be of type Number, but found Null instead."} + ] + } +} diff --git a/test/integration/expression-tests/or/basic/test.json b/test/integration/expression-tests/or/basic/test.json new file mode 100644 index 00000000000..1cf91e2267e --- /dev/null +++ b/test/integration/expression-tests/or/basic/test.json @@ -0,0 +1,19 @@ +{ + "expectExpressionType": null, + "expression": ["||", ["boolean", ["get", "x"]], ["boolean", ["get", "y"]]], + "inputs": [ + [{}, {"properties": {"x": true, "y": true}}], + [{}, {"properties": {"x": true, "y": false}}], + [{}, {"properties": {"x": false, "y": true}}], + [{}, {"properties": {"x": false, "y": false}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [true, true, true, false] + } +} diff --git a/test/integration/expression-tests/parse/empty/test.json b/test/integration/expression-tests/parse/empty/test.json new file mode 100644 index 00000000000..36b8ab4a17d --- /dev/null +++ b/test/integration/expression-tests/parse/empty/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected an array with at least one element. If you wanted a literal array, use [\"literal\", []]." + } + ] + } + } +} diff --git a/test/integration/expression-tests/parse/non-array/test.json b/test/integration/expression-tests/parse/non-array/test.json new file mode 100644 index 00000000000..ed42ef41c48 --- /dev/null +++ b/test/integration/expression-tests/parse/non-array/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["+", ["-", 0, {}], 10], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[1][2]", + "error": "Bare objects invalid. Use [\"literal\", {...}] instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/parse/non-string/test.json b/test/integration/expression-tests/parse/non-string/test.json new file mode 100644 index 00000000000..6810b90b772 --- /dev/null +++ b/test/integration/expression-tests/parse/non-string/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": [1, 2], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[0]", + "error": "Expression name must be a string, but found number instead. If you wanted a literal array, use [\"literal\", [...]]." + } + ] + } + } +} diff --git a/test/integration/expression-tests/parse/unknown-expression/test.json b/test/integration/expression-tests/parse/unknown-expression/test.json new file mode 100644 index 00000000000..77c9accfffb --- /dev/null +++ b/test/integration/expression-tests/parse/unknown-expression/test.json @@ -0,0 +1,15 @@ +{ + "expectExpressionType": null, + "expression": ["+", ["*", 1, 2, 3, ["FAKE-EXPRESSION", 1]], 10], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "[1][4][0]", + "error": "Unknown expression \"FAKE-EXPRESSION\". If you wanted a literal array, use [\"literal\", [...]]." + } + ] + } + } +} diff --git a/test/integration/expression-tests/pi/basic/test.json b/test/integration/expression-tests/pi/basic/test.json new file mode 100644 index 00000000000..cb9a5001596 --- /dev/null +++ b/test/integration/expression-tests/pi/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["pi"], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [3.141592653589793] + } +} diff --git a/test/integration/expression-tests/plus/basic/test.json b/test/integration/expression-tests/plus/basic/test.json new file mode 100644 index 00000000000..3978a3b3046 --- /dev/null +++ b/test/integration/expression-tests/plus/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["+", 1, 2, 3, 4], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [10] + } +} diff --git a/test/integration/expression-tests/pow/basic/test.json b/test/integration/expression-tests/pow/basic/test.json new file mode 100644 index 00000000000..36d5262acb7 --- /dev/null +++ b/test/integration/expression-tests/pow/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["^", 4, ["number", ["get", "x"]]], + "inputs": [[{}, {"properties": {"x": 2}}], [{}, {"properties": {"x": 0.5}}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [16, 2] + } +} diff --git a/test/integration/expression-tests/properties/basic/test.json b/test/integration/expression-tests/properties/basic/test.json new file mode 100644 index 00000000000..043b9b25a37 --- /dev/null +++ b/test/integration/expression-tests/properties/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["properties"], + "inputs": [[{}, {"properties": {"x": 5}}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Object" + }, + "outputs": [{"x": 5}] + } +} diff --git a/test/integration/expression-tests/result_item.html.tmpl b/test/integration/expression-tests/result_item.html.tmpl new file mode 100644 index 00000000000..793f9921894 --- /dev/null +++ b/test/integration/expression-tests/result_item.html.tmpl @@ -0,0 +1,18 @@ + + +

 

+
<%- r.expression %>
+ + +

<%- r.group %>/<%- r.test %>

+
<%- r.difference %>
+ + +

 

+
+
+<%- r.compiledJs %>
+            
+
+ + diff --git a/test/integration/expression-tests/rgb/basic/test.json b/test/integration/expression-tests/rgb/basic/test.json new file mode 100644 index 00000000000..d6f5285cdf5 --- /dev/null +++ b/test/integration/expression-tests/rgb/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["rgb", 0, 0, 255], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Color" + }, + "outputs": [[0, 0, 1, 1]] + } +} diff --git a/test/integration/expression-tests/rgba/basic/test.json b/test/integration/expression-tests/rgba/basic/test.json new file mode 100644 index 00000000000..1d9846a3f00 --- /dev/null +++ b/test/integration/expression-tests/rgba/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["rgba", 0, 0, 255, 1], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Color" + }, + "outputs": [[0, 0, 1, 1]] + } +} diff --git a/test/integration/expression-tests/rgba/out-of-bounds/test.json b/test/integration/expression-tests/rgba/out-of-bounds/test.json new file mode 100644 index 00000000000..8264aa89614 --- /dev/null +++ b/test/integration/expression-tests/rgba/out-of-bounds/test.json @@ -0,0 +1,38 @@ +{ + "expectExpressionType": null, + "expression": [ + "rgba", + 0, + 0, + ["number", ["get", "b"]], + ["number", ["get", "a"]] + ], + "inputs": [ + [{}, {"properties": {"b": -1, "a": 1}}], + [{}, {"properties": {"b": 256, "a": 1}}], + [{}, {"properties": {"b": 255, "a": -0.5}}], + [{}, {"properties": {"b": 256, "a": 1.5}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Color" + }, + "outputs": [ + { + "error": "Invalid rgba value [0, 0, -1, 1]: 'r', 'g', and 'b' must be between 0 and 255." + }, + { + "error": "Invalid rgba value [0, 0, 256, 1]: 'r', 'g', and 'b' must be between 0 and 255." + }, + { + "error": "Invalid rgba value [0, 0, 255, -0.5]: 'a' must be between 0 and 1." + }, + { + "error": "Invalid rgba value [0, 0, 256, 1.5]: 'r', 'g', and 'b' must be between 0 and 255." + } + ] + } +} diff --git a/test/integration/expression-tests/sin/basic/test.json b/test/integration/expression-tests/sin/basic/test.json new file mode 100644 index 00000000000..d926c8b5f8c --- /dev/null +++ b/test/integration/expression-tests/sin/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["sin", 0], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [0] + } +} diff --git a/test/integration/expression-tests/string/basic/test.json b/test/integration/expression-tests/string/basic/test.json new file mode 100644 index 00000000000..aeca30e2530 --- /dev/null +++ b/test/integration/expression-tests/string/basic/test.json @@ -0,0 +1,28 @@ +{ + "expectExpressionType": null, + "expression": ["string", ["get", "x"]], + "inputs": [ + [{}, {"properties": {"x": "1"}}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": null}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "String" + }, + "outputs": [ + "1", + { + "error": "Expected value to be of type String, but found Number instead." + }, + { + "error": "Expected value to be of type String, but found Boolean instead." + }, + {"error": "Expected value to be of type String, but found Null instead."} + ] + } +} diff --git a/test/integration/expression-tests/tan/basic/test.json b/test/integration/expression-tests/tan/basic/test.json new file mode 100644 index 00000000000..c3447a51e5f --- /dev/null +++ b/test/integration/expression-tests/tan/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["tan", 0.7853981633974483], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [0.9999999999999999] + } +} diff --git a/test/integration/expression-tests/times/basic/test.json b/test/integration/expression-tests/times/basic/test.json new file mode 100644 index 00000000000..c4c3a7326a0 --- /dev/null +++ b/test/integration/expression-tests/times/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["*", 3, 2, 0.5, 2], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [6] + } +} diff --git a/test/integration/expression-tests/to-boolean/basic/test.json b/test/integration/expression-tests/to-boolean/basic/test.json new file mode 100644 index 00000000000..5fb6e2b9248 --- /dev/null +++ b/test/integration/expression-tests/to-boolean/basic/test.json @@ -0,0 +1,32 @@ +{ + "expectExpressionType": null, + "expression": ["to-boolean", ["get", "x"]], + "inputs": [ + [{}, {}], + [{}, {"properties": {"x": true}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": ""}}], + [{}, {"properties": {"x": "false"}}], + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": null}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Boolean" + }, + "outputs": [ + {"error": "Property 'x' not found in feature.properties"}, + true, + false, + false, + true, + false, + true, + false + ] + } +} diff --git a/test/integration/expression-tests/to-color/basic/test.json b/test/integration/expression-tests/to-color/basic/test.json new file mode 100644 index 00000000000..e5c5b10260a --- /dev/null +++ b/test/integration/expression-tests/to-color/basic/test.json @@ -0,0 +1,28 @@ +{ + "expectExpressionType": null, + "expression": ["to-color", ["get", "x"]], + "inputs": [ + [{}, {"properties": {"x": "red"}}], + [{}, {"properties": {"x": "invalid"}}], + [{}, {"properties": {"x": "rgba(0, 255, 0, 1)"}}], + [{}, {"properties": {"x": [0, 255, 0, 1]}}], + [{}, {"properties": {"x": [0, 255, 0]}}], + [{}, {"properties": {"x": [0, 255]}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Color" + }, + "outputs": [ + [1, 0, 0, 1], + {"error": "Could not parse color from value 'invalid'"}, + [0, 1, 0, 1], + [0, 1, 0, 1], + [0, 1, 0, 1], + {"error": "Could not parse color from value '[0,255]'"} + ] + } +} diff --git a/test/integration/expression-tests/to-number/basic/test.json b/test/integration/expression-tests/to-number/basic/test.json new file mode 100644 index 00000000000..832191dabca --- /dev/null +++ b/test/integration/expression-tests/to-number/basic/test.json @@ -0,0 +1,30 @@ +{ + "expectExpressionType": null, + "expression": ["to-number", ["get", "x"]], + "inputs": [ + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": "1"}}], + [{}, {"properties": {"x": "6.02e-23"}}], + [{}, {"properties": {"x": "Not a number"}}], + [{}, {"properties": {"x": null}}], + [{}, {"properties": {"x": [1, 2]}}], + [{}, {"properties": {"x": {"y": 1}}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Number" + }, + "outputs": [ + 1, + 1, + 6.02e-23, + {"error": "Could not convert \"Not a number\" to number."}, + {"error": "Could not convert null to number."}, + {"error": "Could not convert [1,2] to number."}, + {"error": "Could not convert {\"y\":1} to number."} + ] + } +} diff --git a/test/integration/expression-tests/to-string/basic/test.json b/test/integration/expression-tests/to-string/basic/test.json new file mode 100644 index 00000000000..74bc282374e --- /dev/null +++ b/test/integration/expression-tests/to-string/basic/test.json @@ -0,0 +1,30 @@ +{ + "expectExpressionType": null, + "expression": ["to-string", ["get", "x"]], + "inputs": [ + [{}, {"properties": {"x": 1}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": null}}], + [{}, {"properties": {"x": [1, 2]}}], + [{}, {"properties": {"x": {"y": 1}}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "String" + }, + "outputs": [ + "1", + "false", + "null", + { + "error": "Expected a primitive value in [\"string\", ...], but found Array instead." + }, + { + "error": "Expected a primitive value in [\"string\", ...], but found Object instead." + } + ] + } +} diff --git a/test/integration/expression-tests/typecheck/array-invalid-item/test.json b/test/integration/expression-tests/typecheck/array-invalid-item/test.json new file mode 100644 index 00000000000..ba119ab9211 --- /dev/null +++ b/test/integration/expression-tests/typecheck/array-invalid-item/test.json @@ -0,0 +1,20 @@ +{ + "expectExpressionType": { + "kind": "Array", + "itemType": {"kind": "String"}, + "N": 2 + }, + "expression": ["array", "number", 2, ["get", "x"]], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected Array but found Array instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/typecheck/array-item-subtyping/test.json b/test/integration/expression-tests/typecheck/array-item-subtyping/test.json new file mode 100644 index 00000000000..db41ce2b3ef --- /dev/null +++ b/test/integration/expression-tests/typecheck/array-item-subtyping/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": {"kind": "Array", "itemType": {"kind": "Value"}}, + "expression": ["array", "number", 2, ["get", "x"]], + "inputs": [], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [] + } +} diff --git a/test/integration/expression-tests/typecheck/array-length-subtyping--no-length/test.json b/test/integration/expression-tests/typecheck/array-length-subtyping--no-length/test.json new file mode 100644 index 00000000000..37651d925ca --- /dev/null +++ b/test/integration/expression-tests/typecheck/array-length-subtyping--no-length/test.json @@ -0,0 +1,20 @@ +{ + "expectExpressionType": { + "kind": "Array", + "itemType": {"kind": "Number"}, + "N": 3 + }, + "expression": ["array", "number", ["get", "x"]], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected Array but found Array instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/typecheck/array-length-subtyping/test.json b/test/integration/expression-tests/typecheck/array-length-subtyping/test.json new file mode 100644 index 00000000000..5f1fceed6dc --- /dev/null +++ b/test/integration/expression-tests/typecheck/array-length-subtyping/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": {"kind": "Array", "itemType": {"kind": "String"}}, + "expression": ["array", "string", 2, ["get", "x"]], + "inputs": [], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "Array" + }, + "outputs": [] + } +} diff --git a/test/integration/expression-tests/typecheck/array-wrong-length/test.json b/test/integration/expression-tests/typecheck/array-wrong-length/test.json new file mode 100644 index 00000000000..758c40e8147 --- /dev/null +++ b/test/integration/expression-tests/typecheck/array-wrong-length/test.json @@ -0,0 +1,20 @@ +{ + "expectExpressionType": { + "kind": "Array", + "itemType": {"kind": "Number"}, + "N": 3 + }, + "expression": ["array", "number", 2, ["get", "x"]], + "inputs": [], + "expected": { + "compiled": { + "result": "error", + "errors": [ + { + "key": "", + "error": "Expected Array but found Array instead." + } + ] + } + } +} diff --git a/test/integration/expression-tests/typeof/basic/test.json b/test/integration/expression-tests/typeof/basic/test.json new file mode 100644 index 00000000000..57712715a3f --- /dev/null +++ b/test/integration/expression-tests/typeof/basic/test.json @@ -0,0 +1,34 @@ +{ + "expectExpressionType": null, + "expression": ["typeof", ["get", "x"]], + "inputs": [ + [{}, {"properties": {"x": null}}], + [{}, {"properties": {"x": "s"}}], + [{}, {"properties": {"x": 0}}], + [{}, {"properties": {"x": false}}], + [{}, {"properties": {"x": [1, 2, 3]}}], + [{}, {"properties": {"x": ["a", "b", "c"]}}], + [{}, {"properties": {"x": [true, false]}}], + [{}, {"properties": {"x": [1, false]}}], + [{}, {"properties": {"x": {}}}] + ], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": false, + "isZoomConstant": true, + "type": "String" + }, + "outputs": [ + "Null", + "String", + "Number", + "Boolean", + "Array", + "Array", + "Array", + "Array", + "Object" + ] + } +} diff --git a/test/integration/expression-tests/upcase/basic/test.json b/test/integration/expression-tests/upcase/basic/test.json new file mode 100644 index 00000000000..f0d7838ede2 --- /dev/null +++ b/test/integration/expression-tests/upcase/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["upcase", "string"], + "inputs": [[{}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": true, + "type": "String" + }, + "outputs": ["STRING"] + } +} diff --git a/test/integration/expression-tests/zoom/basic/test.json b/test/integration/expression-tests/zoom/basic/test.json new file mode 100644 index 00000000000..6d237b0d34a --- /dev/null +++ b/test/integration/expression-tests/zoom/basic/test.json @@ -0,0 +1,14 @@ +{ + "expectExpressionType": null, + "expression": ["curve", ["linear"], ["zoom"], 0, 0, 30, 30], + "inputs": [[{"zoom": 5}, {}]], + "expected": { + "compiled": { + "result": "success", + "isFeatureConstant": true, + "isZoomConstant": false, + "type": "Number" + }, + "outputs": [5] + } +} diff --git a/test/integration/index.js b/test/integration/index.js index 7b6253e9c71..016704a21f2 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -2,3 +2,4 @@ module.exports.render = require('./lib/render'); module.exports.query = require('./lib/query'); +module.exports.expression = require('./lib/expression'); diff --git a/test/integration/lib/expression.js b/test/integration/lib/expression.js new file mode 100644 index 00000000000..cff81de7660 --- /dev/null +++ b/test/integration/lib/expression.js @@ -0,0 +1,139 @@ +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const harness = require('./harness'); +const diff = require('diff'); +const fs = require('fs'); +const stringify = require('json-stringify-pretty-compact'); + +let linter; +try { + const Linter = require('eslint').Linter; + linter = new Linter(); +} catch (_) { + // eslint-disable-line +} + +const floatPrecision = 6; // in decimal sigfigs + +function deepEqual(a, b) { + if (typeof a !== typeof b) + return false; + if (typeof a === 'number') { + if (a === 0) { return b === 0; } + const digits = 1 + Math.floor(Math.log10(Math.abs(a))); + const multiplier = Math.pow(10, floatPrecision - digits); + return Math.floor(a * multiplier) === Math.floor(b * multiplier); + } + if (a === null || b === null || typeof a !== 'object') + return a === b; + + const ka = Object.keys(a); + const kb = Object.keys(b); + + if (ka.length !== kb.length) + return false; + + ka.sort(); + kb.sort(); + + for (let i = 0; i < ka.length; i++) + if (ka[i] !== kb[i] || !deepEqual(a[ka[i]], b[ka[i]])) + return false; + + return true; +} +/** + * Run the expression suite. + * + * @param {string} implementation - identify the implementation under test; used to + * deal with implementation-specific test exclusions and fudge-factors + * @param {Object} options + * @param {Array} [options.tests] - array of test names to run; tests not in the + * array will be skipped + * @param {} runExpressionTest - a function that runs a single expression test fixture + * @returns {undefined} terminates the process when testing is complete + */ +exports.run = function (implementation, options, runExpressionTest) { + const directory = path.join(__dirname, '../expression-tests'); + options.fixtureFilename = 'test.json'; + harness(directory, implementation, options, (fixture, params, done) => { + try { + const result = runExpressionTest(fixture, params); + const dir = path.join(directory, params.group, params.test); + + if (process.env.UPDATE) { + delete result.compiled.functionSource; + fixture.expected = result; + fs.writeFile(path.join(dir, 'test.json'), `${stringify(fixture, null, 2)}\n`, done); + return; + } + + const expected = fixture.expected; + + if (result.compiled.functionSource) { + assert(linter); + params.compiledJs = result.compiled.functionSource; + delete result.compiled.functionSource; + let lint = linter.verify(params.compiledJs, { + parserOptions: { ecmaVersion: 5 } + }, {filename: dir}); + if (lint.filter(message => message.fatal).length > 0) { + result.compiled.lintErrors = lint; + } else { + const code = params.compiledJs.replace(/\{/g, '{\n'); + lint = linter.verifyAndFix(code, { + parserOptions: { ecmaVersion: 5 }, + rules: { + indent: ['error', 2] + } + }, {filename: dir}); + if (lint.fixed) { + params.compiledJs = lint.output; + } + } + } + + const compileOk = deepEqual(result.compiled, expected.compiled); + + const evalOk = compileOk && deepEqual(result.outputs, expected.outputs); + params.ok = compileOk && evalOk; + + let msg = ''; + if (!compileOk) { + msg += diff.diffJson(expected.compiled, result.compiled) + .map((hunk) => { + if (hunk.added) { + return `+ ${hunk.value}`; + } else if (hunk.removed) { + return `- ${hunk.value}`; + } else { + return ` ${hunk.value}`; + } + }) + .join(''); + } + if (compileOk && !evalOk) { + msg += expected.outputs + .map((expectedOutput, i) => { + if (!deepEqual(expectedOutput, result.outputs[i])) { + return `f(${JSON.stringify(fixture.inputs[i])})\nExpected: ${JSON.stringify(expectedOutput)}\nActual: ${JSON.stringify(result.outputs[i])}`; + } + return false; + }) + .filter(Boolean) + .join('\n'); + } + + params.difference = msg; + if (msg) { console.log(msg); } + + params.expression = JSON.stringify(fixture.expression, null, 2); + + done(); + } catch (e) { + done(e); + } + }); +}; diff --git a/test/integration/lib/harness.js b/test/integration/lib/harness.js index fbacd0aacb0..b00d9b88ecc 100644 --- a/test/integration/lib/harness.js +++ b/test/integration/lib/harness.js @@ -17,6 +17,8 @@ module.exports = function (directory, implementation, options, run) { const ignores = options.ignores || {}; const available = []; + const fixtureFilename = options.fixtureFilename || 'style.json'; + fs.readdirSync(directory).forEach((group) => { if ( group === 'index.html' || @@ -42,10 +44,10 @@ module.exports = function (directory, implementation, options, run) { try { if (!fs.lstatSync(path.join(directory, group, test)).isDirectory()) return false; - if (!fs.lstatSync(path.join(directory, group, test, 'style.json')).isFile()) + if (!fs.lstatSync(path.join(directory, group, test, fixtureFilename)).isFile()) return false; } catch (err) { - console.log(colors.blue(`* omitting ${group} ${test} due to missing style`)); + console.log(colors.blue(`* omitting ${group} ${test} due to missing ${fixtureFilename}`)); return false; } @@ -65,7 +67,10 @@ module.exports = function (directory, implementation, options, run) { } function addTestToSequence(group, test) { - const style = require(path.join(directory, group, test, 'style.json')); + // Skip ignored and malformed tests. + if (!shouldRunTest(group, test)) return; + + const style = require(path.join(directory, group, test, fixtureFilename)); server.localizeURLs(style); @@ -118,8 +123,6 @@ module.exports = function (directory, implementation, options, run) { }); } - // Skip ignored and malformed tests. - sequence = sequence.filter((value) => { return shouldRunTest(value.params.group, value.params.test); }); if (options.shuffle) { console.log(colors.white(`* shuffle seed: `) + colors.bold(`${options.seed}`)); diff --git a/test/integration/render-tests/icon-size/composite-function-high-base-plain/expected.png b/test/integration/render-tests/icon-size/composite-function-high-base-plain/expected.png deleted file mode 100644 index 101e0bc8134..00000000000 Binary files a/test/integration/render-tests/icon-size/composite-function-high-base-plain/expected.png and /dev/null differ diff --git a/test/integration/render-tests/icon-size/composite-function-high-base-plain/style.json b/test/integration/render-tests/icon-size/composite-function-high-base-plain/style.json deleted file mode 100644 index c36c9cd90ed..00000000000 --- a/test/integration/render-tests/icon-size/composite-function-high-base-plain/style.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "version": 8, - "metadata": { - "test": { - "width": 64, - "height": 64 - } - }, - "zoom": 0.5, - "sources": { - "geojson": { - "type": "geojson", - "data": { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": { - "x": 0 - }, - "geometry": { - "type": "Point", - "coordinates": [ - 0, - 0 - ] - } - } - ] - } - } - }, - "glyphs": "local://glyphs/{fontstack}/{range}.pbf", - "sprite": "local://sprites/sprite", - "layers": [ - { - "id": "symbol", - "type": "symbol", - "source": "geojson", - "layout": { - "icon-size": { - "base": 99, - "property": "x", - "stops": [ - [ - { - "zoom": 0, - "value": 0 - }, - 0.5 - ], - [ - { - "zoom": 1, - "value": 0 - }, - 1.5 - ] - ] - }, - "icon-image": "restaurant-12" - }, - "paint": { - "icon-color": "red" - } - } - ] -} \ No newline at end of file diff --git a/test/integration/render-tests/icon-size/composite-function-high-base-sdf/expected.png b/test/integration/render-tests/icon-size/composite-function-high-base-sdf/expected.png deleted file mode 100644 index 739e465ce57..00000000000 Binary files a/test/integration/render-tests/icon-size/composite-function-high-base-sdf/expected.png and /dev/null differ diff --git a/test/integration/render-tests/icon-size/composite-function-high-base-sdf/style.json b/test/integration/render-tests/icon-size/composite-function-high-base-sdf/style.json deleted file mode 100644 index 86b6ac1fd88..00000000000 --- a/test/integration/render-tests/icon-size/composite-function-high-base-sdf/style.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "version": 8, - "metadata": { - "test": { - "width": 64, - "height": 64 - } - }, - "zoom": 0.5, - "sources": { - "geojson": { - "type": "geojson", - "data": { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": { - "x": 0 - }, - "geometry": { - "type": "Point", - "coordinates": [ - 0, - 0 - ] - } - } - ] - } - } - }, - "glyphs": "local://glyphs/{fontstack}/{range}.pbf", - "sprite": "local://sprites/sprite", - "layers": [ - { - "id": "symbol", - "type": "symbol", - "source": "geojson", - "layout": { - "icon-size": { - "property": "x", - "base": 99, - "stops": [ - [ - { - "zoom": 0, - "value": 0 - }, - 1 - ], - [ - { - "zoom": 1, - "value": 0 - }, - 2 - ] - ] - }, - "icon-image": "dot.sdf" - }, - "paint": { - "icon-color": "red" - } - } - ] -} \ No newline at end of file diff --git a/test/integration/render-tests/line-opacity/step-curve/expected.png b/test/integration/render-tests/line-opacity/step-curve/expected.png new file mode 100644 index 00000000000..4cc39724f3f Binary files /dev/null and b/test/integration/render-tests/line-opacity/step-curve/expected.png differ diff --git a/test/integration/render-tests/line-opacity/step-curve/style.json b/test/integration/render-tests/line-opacity/step-curve/style.json new file mode 100644 index 00000000000..5bb36fc91b2 --- /dev/null +++ b/test/integration/render-tests/line-opacity/step-curve/style.json @@ -0,0 +1,86 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 64, + "width": 64 + } + }, + "center": [ + 0, + 0 + ], + "zoom": 0.99, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "class": "motorway" }, + "geometry": { + "type": "LineString", + "coordinates": [ + [-10, 10], + [10, -10] + ] + } + }, + { + "type": "Feature", + "properties": { "class": "trunk" }, + "geometry": { + "type": "LineString", + "coordinates": [ + [10, 10], + [-10, -10] + ] + } + }, + { + "type": "Feature", + "properties": { "class": "other" }, + "geometry": { + "type": "LineString", + "coordinates": [ + [-10, 0], + [10, 0] + ] + } + } + ] + } + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "road", + "type": "line", + "source": "geojson", + "paint": { + "line-width": 10, + "line-opacity": { + "expression": [ + "curve", ["step"], ["zoom"], + ["match", ["string", ["get", "class"]], + "motorway", 1, + "trunk", 0.25, + 0.1 + ], + 1, + 1 + ] + } + } + } + ] +} diff --git a/test/integration/render-tests/text-font/camera-function/expected.png b/test/integration/render-tests/text-font/camera-function/expected.png new file mode 100644 index 00000000000..cbb2c6e7c9d Binary files /dev/null and b/test/integration/render-tests/text-font/camera-function/expected.png differ diff --git a/test/integration/render-tests/text-font/camera-function/style.json b/test/integration/render-tests/text-font/camera-function/style.json new file mode 100644 index 00000000000..379736fe3c9 --- /dev/null +++ b/test/integration/render-tests/text-font/camera-function/style.json @@ -0,0 +1,56 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 64, + "width": 64 + } + }, + "center": [ + 90, + 45 + ], + "zoom": 1, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Point", + "coordinates": [90, 45] + } + } + } + }, + "glyphs": "local://glyphs/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "symbol", + "type": "symbol", + "source": "geojson", + "layout": { + "text-field": "Test", + "text-font": { + "expression": [ + "curve", ["step"], ["zoom"], + [ "literal", [ "Open Sans Semibold", "Arial Unicode MS Bold" ]], + 1, + ["literal", [ "Open Sans Semibold", "Arial Unicode MS Bold" ]] + ] + } + }, + "paint": { + "text-opacity": 1 + } + } + ] +} diff --git a/test/integration/render-tests/text-size/composite-expression/expected.png b/test/integration/render-tests/text-size/composite-expression/expected.png new file mode 100644 index 00000000000..9634c56a1e1 Binary files /dev/null and b/test/integration/render-tests/text-size/composite-expression/expected.png differ diff --git a/test/integration/render-tests/text-size/composite-function-high-base/style.json b/test/integration/render-tests/text-size/composite-expression/style.json similarity index 52% rename from test/integration/render-tests/text-size/composite-function-high-base/style.json rename to test/integration/render-tests/text-size/composite-expression/style.json index ec262fc994f..0f1666f1291 100644 --- a/test/integration/render-tests/text-size/composite-function-high-base/style.json +++ b/test/integration/render-tests/text-size/composite-expression/style.json @@ -15,22 +15,15 @@ "features": [ { "type": "Feature", - "properties": { - "x": 0 - }, + "properties": { "x": 0 }, "geometry": { "type": "Point", - "coordinates": [ - -10, - 0 - ] + "coordinates": [ -10, 0 ] } }, { "type": "Feature", - "properties": { - "x": 5 - }, + "properties": { "x": 5 }, "geometry": { "type": "Point", "coordinates": [ @@ -45,6 +38,23 @@ }, "glyphs": "local://glyphs/{fontstack}/{range}.pbf", "layers": [ + { + "id": "reference", + "type": "symbol", + "source": "geojson", + "layout": { + "text-field": "A", + "text-font": [ + "Open Sans Semibold", + "Arial Unicode MS Bold" + ], + "text-size": { + "expression": [ "match", ["number", ["get", "x"]], 5, 24, 12] + }, + "text-ignore-placement": true, + "text-allow-overlap": true + } + }, { "id": "symbol", "type": "symbol", @@ -56,40 +66,20 @@ "Arial Unicode MS Bold" ], "text-size": { - "base": 99, - "property": "x", - "stops": [ - [ - { - "value": 0, - "zoom": 0 - }, - 10 - ], - [ - { - "value": 10, - "zoom": 0 - }, - 4 - ], - [ - { - "value": 0, - "zoom": 1 - }, - 14 - ], - [ - { - "value": 10, - "zoom": 1 - }, - 44 - ] + "expression": [ + "curve", + ["cubic-bezier", 0, 0.9, 0.1, 1], + ["zoom"], + 0, + 2, + 1, + [ "match", ["number", ["get", "x"]], 5, 24, 12] ] - } + }, + "text-offset": [0, 1], + "text-ignore-placement": true, + "text-allow-overlap": true } } ] -} \ No newline at end of file +} diff --git a/test/integration/render-tests/text-size/composite-function-high-base/expected.png b/test/integration/render-tests/text-size/composite-function-high-base/expected.png deleted file mode 100644 index d931135eed6..00000000000 Binary files a/test/integration/render-tests/text-size/composite-function-high-base/expected.png and /dev/null differ diff --git a/test/unit/style-spec/fixture/functions.input.json b/test/unit/style-spec/fixture/functions.input.json index 0ea12eaacd1..267e3be5fe2 100644 --- a/test/unit/style-spec/fixture/functions.input.json +++ b/test/unit/style-spec/fixture/functions.input.json @@ -879,6 +879,50 @@ ] } } + }, + { + "id": "valid expression", + "type": "fill", + "source": "source", + "source-layer": "layer", + "paint": { + "fill-color": { + "expression": ["rgba", 10, ["number", ["get", "x"]], 30, 1] + } + } + }, + { + "id": "invalid expression type - color", + "type": "fill", + "source": "source", + "source-layer": "layer", + "paint": { + "fill-color": { + "expression": ["pi"] + } + } + }, + { + "id": "invalid expression - fails type checking", + "type": "fill", + "source": "source", + "source-layer": "layer", + "paint": { + "fill-color": { + "expression": ["rgba", 1, "should be a number", 0, 1] + } + } + }, + { + "id": "invalid expression - nested zoom expression", + "type": "fill", + "source": "source", + "source-layer": "layer", + "paint": { + "fill-opacity": { + "expression": ["+", 0.5, ["curve", ["linear"], ["zoom"], 0, 0, 1, 1]] + } + } } ] } diff --git a/test/unit/style-spec/fixture/functions.output.json b/test/unit/style-spec/fixture/functions.output.json index ca6101efda3..95c5e0a7e5a 100644 --- a/test/unit/style-spec/fixture/functions.output.json +++ b/test/unit/style-spec/fixture/functions.output.json @@ -83,9 +83,6 @@ "message": "layers[22].paint.fill-color: missing required property \"property\"", "line": 401 }, - { - "message": "layers[32].paint.fill-color.stops[0][0]: stop domain value must be a number, string, or boolean" - }, { "message": "layers[23].paint.fill-color.stops: identity function may not have a \"stops\" property", "line": 415 @@ -118,6 +115,10 @@ "message": "layers[31].paint.fill-color.stops[0][0]: stop domain value must be a number, string, or boolean", "line": 564 }, + { + "message": "layers[32].paint.fill-color.stops[0][0]: stop domain value must be a number, string, or boolean", + "line": 581 + }, { "message": "layers[33].paint.fill-antialias: exponential functions not supported", "line": 595 @@ -161,5 +162,17 @@ { "message": "layers[44].paint.fill-opacity.stops[1][0]: stop domain values must appear in ascending order", "line": 857 + }, + { + "message": "layers[47].paint.fill-color.expression: Expected Color but found Number instead.", + "line": 900 + }, + { + "message": "layers[48].paint.fill-color.expression[2]: Expected Number but found String instead.", + "line": 911 + }, + { + "message": "layers[49].paint.fill-opacity.expression: \"zoom\" expression may only be used as input to a top-level \"curve\" expression.", + "line": 922 } ] \ No newline at end of file diff --git a/test/unit/style-spec/function.test.js b/test/unit/style-spec/function.test.js index ff3444fa793..5ab682fdeec 100644 --- a/test/unit/style-spec/function.test.js +++ b/test/unit/style-spec/function.test.js @@ -7,9 +7,9 @@ test('constant function', (t) => { t.test('number', (t) => { const f = createFunction(1, {type: 'number'}); - t.equal(f(0), 1); - t.equal(f(1), 1); - t.equal(f(2), 1); + t.equal(f({zoom: 0}), 1); + t.equal(f({zoom: 1}), 1); + t.equal(f({zoom: 2}), 1); t.end(); }); @@ -17,9 +17,9 @@ test('constant function', (t) => { t.test('string', (t) => { const f = createFunction('mapbox', {type: 'string'}); - t.equal(f(0), 'mapbox'); - t.equal(f(1), 'mapbox'); - t.equal(f(2), 'mapbox'); + t.equal(f({zoom: 0}), 'mapbox'); + t.equal(f({zoom: 1}), 'mapbox'); + t.equal(f({zoom: 2}), 'mapbox'); t.end(); }); @@ -27,9 +27,9 @@ test('constant function', (t) => { t.test('color', (t) => { const f = createFunction('red', {type: 'color'}); - t.deepEqual(f(0), [1, 0, 0, 1]); - t.deepEqual(f(1), [1, 0, 0, 1]); - t.deepEqual(f(2), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 0}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 1}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 2}), [1, 0, 0, 1]); t.end(); }); @@ -37,9 +37,9 @@ test('constant function', (t) => { t.test('array', (t) => { const f = createFunction([1], {type: 'array'}); - t.deepEqual(f(0), [1]); - t.deepEqual(f(1), [1]); - t.deepEqual(f(2), [1]); + t.deepEqual(f({zoom: 0}), [1]); + t.deepEqual(f({zoom: 1}), [1]); + t.deepEqual(f({zoom: 2}), [1]); t.end(); }); @@ -57,7 +57,7 @@ test('binary search', (t) => { function: 'interpolated' }); - t.equal(f(17), 11); + t.equal(f({zoom: 17}), 11); t.end(); }); @@ -74,7 +74,7 @@ test('exponential function', (t) => { function: 'interpolated' }); - t.equalWithPrecision(f(2), 30 / 9, 1e-6); + t.equalWithPrecision(f({zoom: 2}), 30 / 9, 1e-6); t.end(); }); @@ -88,11 +88,11 @@ test('exponential function', (t) => { type: 'number' }); - t.equalWithPrecision(f(0), 2, 1e-6); - t.equalWithPrecision(f(1), 2, 1e-6); - t.equalWithPrecision(f(2), 30 / 9, 1e-6); - t.equalWithPrecision(f(3), 6, 1e-6); - t.equalWithPrecision(f(4), 6, 1e-6); + t.equalWithPrecision(f({zoom: 0}), 2, 1e-6); + t.equalWithPrecision(f({zoom: 1}), 2, 1e-6); + t.equalWithPrecision(f({zoom: 2}), 30 / 9, 1e-6); + t.equalWithPrecision(f({zoom: 3}), 6, 1e-6); + t.equalWithPrecision(f({zoom: 4}), 6, 1e-6); t.end(); }); @@ -105,9 +105,9 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(0), 2); - t.equal(f(1), 2); - t.equal(f(2), 2); + t.equal(f({zoom: 0}), 2); + t.equal(f({zoom: 1}), 2); + t.equal(f({zoom: 2}), 2); t.end(); }); @@ -120,11 +120,11 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(0), 2); - t.equal(f(1), 2); - t.equal(f(2), 4); - t.equal(f(3), 6); - t.equal(f(4), 6); + t.equal(f({zoom: 0}), 2); + t.equal(f({zoom: 1}), 2); + t.equal(f({zoom: 2}), 4); + t.equal(f({zoom: 3}), 6); + t.equal(f({zoom: 4}), 6); t.end(); }); @@ -137,15 +137,15 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(0), 2); - t.equal(f(1), 2); - t.equal(f(2), 4); - t.equal(f(2.5), 5); - t.equal(f(3), 6); - t.equal(f(4), 8); - t.equal(f(4.5), 9); - t.equal(f(5), 10); - t.equal(f(6), 10); + t.equal(f({zoom: 0}), 2); + t.equal(f({zoom: 1}), 2); + t.equal(f({zoom: 2}), 4); + t.equal(f({zoom: 2.5}), 5); + t.equal(f({zoom: 3}), 6); + t.equal(f({zoom: 4}), 8); + t.equal(f({zoom: 4.5}), 9); + t.equal(f({zoom: 5}), 10); + t.equal(f({zoom: 6}), 10); t.end(); }); @@ -158,19 +158,19 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(0), 2); - t.equal(f(1), 2); - t.equal(f(2), 4); - t.equal(f(2.5), 5); - t.equal(f(3), 6); - t.equal(f(3.5), 7); - t.equal(f(4), 8); - t.equal(f(4.5), 9); - t.equal(f(5), 10); - t.equal(f(6), 12); - t.equal(f(6.5), 13); - t.equal(f(7), 14); - t.equal(f(8), 14); + t.equal(f({zoom: 0}), 2); + t.equal(f({zoom: 1}), 2); + t.equal(f({zoom: 2}), 4); + t.equal(f({zoom: 2.5}), 5); + t.equal(f({zoom: 3}), 6); + t.equal(f({zoom: 3.5}), 7); + t.equal(f({zoom: 4}), 8); + t.equal(f({zoom: 4.5}), 9); + t.equal(f({zoom: 5}), 10); + t.equal(f({zoom: 6}), 12); + t.equal(f({zoom: 6.5}), 13); + t.equal(f({zoom: 7}), 14); + t.equal(f({zoom: 8}), 14); t.end(); }); @@ -199,15 +199,15 @@ test('exponential function', (t) => { type: 'number' }); - t.equalWithPrecision(f(2), 100, 1e-6); - t.equalWithPrecision(f(20), 133.9622641509434, 1e-6); - t.equalWithPrecision(f(607), 400, 1e-6); - t.equalWithPrecision(f(680), 410.7352941176471, 1e-6); - t.equalWithPrecision(f(4927), 1000, 1e-6); //86 - t.equalWithPrecision(f(7300), 14779.590419993057, 1e-6); - t.equalWithPrecision(f(10000), 99125.30371398819, 1e-6); - t.equalWithPrecision(f(20000), 3360628.527166095, 1e-6); - t.equalWithPrecision(f(40000), 10000000, 1e-6); + t.equalWithPrecision(f({zoom: 2}), 100, 1e-6); + t.equalWithPrecision(f({zoom: 20}), 133.9622641509434, 1e-6); + t.equalWithPrecision(f({zoom: 607}), 400, 1e-6); + t.equalWithPrecision(f({zoom: 680}), 410.7352941176471, 1e-6); + t.equalWithPrecision(f({zoom: 4927}), 1000, 1e-6); //86 + t.equalWithPrecision(f({zoom: 7300}), 14779.590419993057, 1e-6); + t.equalWithPrecision(f({zoom: 10000}), 99125.30371398819, 1e-6); + t.equalWithPrecision(f({zoom: 20000}), 3360628.527166095, 1e-6); + t.equalWithPrecision(f({zoom: 40000}), 10000000, 1e-6); t.end(); }); @@ -220,14 +220,14 @@ test('exponential function', (t) => { type: 'color' }); - t.deepEqual(f(0), [1, 0, 0, 1]); - t.deepEqual(f(5), [0.6, 0, 0.4, 1]); - t.deepEqual(f(11), [0, 0, 1, 1]); + t.deepEqual(f({zoom: 0}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 5}), [0.6, 0, 0.4, 1]); + t.deepEqual(f({zoom: 11}), [0, 0, 1, 1]); t.end(); }); - t.test('lab colorspace', (t) => { + t.test('lab colorspace', {skip: true}, (t) => { const f = createFunction({ type: 'exponential', colorSpace: 'lab', @@ -236,15 +236,15 @@ test('exponential function', (t) => { type: 'color' }); - t.deepEqual(f(0), [0, 0, 0, 1]); - t.deepEqual(f(5).map((n) => { + t.deepEqual(f({zoom: 0}), [0, 0, 0, 1]); + t.deepEqual(f({zoom: 5}).map((n) => { return parseFloat(n.toFixed(3)); }), [0, 0.444, 0.444, 1]); t.end(); }); - t.test('rgb colorspace', (t) => { + t.test('rgb colorspace', {skip: true}, (t) => { const f = createFunction({ type: 'exponential', colorSpace: 'rgb', @@ -253,14 +253,14 @@ test('exponential function', (t) => { type: 'color' }); - t.deepEqual(f(5).map((n) => { + t.deepEqual(f({zoom: 5}).map((n) => { return parseFloat(n.toFixed(3)); }), [0.5, 0.5, 0.5, 1]); t.end(); }); - t.test('unknown color spaces', (t) => { + t.test('unknown color spaces', {skip: true}, (t) => { t.throws(() => { createFunction({ type: 'exponential', @@ -274,7 +274,7 @@ test('exponential function', (t) => { t.end(); }); - t.test('interpolation mutation avoidance', (t) => { + t.test('interpolation mutation avoidance', {skip: true}, (t) => { const params = { type: 'exponential', colorSpace: 'lab', @@ -297,7 +297,7 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(0, {foo: 1}), 2); + t.equal(f({zoom: 0}, {properties: {foo: 1}}), 2); t.end(); }); @@ -312,7 +312,7 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(0, {}), 3); + t.equal(f({zoom: 0}, {properties: {}}), 3); t.end(); }); @@ -327,12 +327,12 @@ test('exponential function', (t) => { default: 3 }); - t.equal(f(0, {}), 3); + t.equal(f({zoom: 0}, {properties: {}}), 3); t.end(); }); - t.test('property type mismatch, function default', (t) => { + t.test('property type mismatch, function default', {skip: true}, (t) => { const f = createFunction({ property: 'foo', type: 'exponential', @@ -342,12 +342,12 @@ test('exponential function', (t) => { type: 'string' }); - t.equal(f(0, {foo: 'string'}), 3); + t.equal(f({zoom: 0}, {properties: {foo: 'string'}}), 3); t.end(); }); - t.test('property type mismatch, spec default', (t) => { + t.test('property type mismatch, spec default', {skip: true}, (t) => { const f = createFunction({ property: 'foo', type: 'exponential', @@ -357,7 +357,7 @@ test('exponential function', (t) => { default: 3 }); - t.equal(f(0, {foo: 'string'}), 3); + t.equal(f({zoom: 0}, {properties: {foo: 'string'}}), 3); t.end(); }); @@ -371,15 +371,15 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(0, { prop: 0 }), 2); - t.equal(f(1, { prop: 0 }), 2); - t.equal(f(2, { prop: 0 }), 2); - t.equal(f(0, { prop: 1 }), 2); - t.equal(f(1, { prop: 1 }), 2); - t.equal(f(2, { prop: 1 }), 2); - t.equal(f(0, { prop: 2 }), 2); - t.equal(f(1, { prop: 2 }), 2); - t.equal(f(2, { prop: 2 }), 2); + t.equal(f({zoom: 0}, {properties: { prop: 0 }}), 2); + t.equal(f({zoom: 1}, {properties: { prop: 0 }}), 2); + t.equal(f({zoom: 2}, {properties: { prop: 0 }}), 2); + t.equal(f({zoom: 0}, {properties: { prop: 1 }}), 2); + t.equal(f({zoom: 1}, {properties: { prop: 1 }}), 2); + t.equal(f({zoom: 2}, {properties: { prop: 1 }}), 2); + t.equal(f({zoom: 0}, {properties: { prop: 2 }}), 2); + t.equal(f({zoom: 1}, {properties: { prop: 2 }}), 2); + t.equal(f({zoom: 2}, {properties: { prop: 2 }}), 2); t.end(); }); @@ -398,16 +398,16 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(0, { prop: 1 }), 2); - t.equal(f(1, { prop: 1 }), 2); - t.equal(f(2, { prop: 1 }), 4); - t.equal(f(3, { prop: 1 }), 6); - t.equal(f(4, { prop: 1 }), 6); + t.equal(f({zoom: 0}, {properties: { prop: 1 }}), 2); + t.equal(f({zoom: 1}, {properties: { prop: 1 }}), 2); + t.equal(f({zoom: 2}, {properties: { prop: 1 }}), 4); + t.equal(f({zoom: 3}, {properties: { prop: 1 }}), 6); + t.equal(f({zoom: 4}, {properties: { prop: 1 }}), 6); - t.equal(f(2, { prop: -1}), 0); - t.equal(f(2, { prop: 0}), 0); - t.equal(f(2, { prop: 2}), 8); - t.equal(f(2, { prop: 3}), 8); + t.equal(f({zoom: 2}, {properties: { prop: -1}}), 0); + t.equal(f({zoom: 2}, {properties: { prop: 0}}), 0); + t.equal(f({zoom: 2}, {properties: { prop: 2}}), 8); + t.equal(f({zoom: 2}, {properties: { prop: 3}}), 8); t.end(); }); @@ -428,9 +428,9 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(0, { prop: 1 }), 2); - t.equal(f(1, { prop: 1 }), 2); - t.equal(f(2, { prop: 1 }), 4); + t.equal(f({zoom: 0}, {properties: { prop: 1 }}), 2); + t.equal(f({zoom: 1}, {properties: { prop: 1 }}), 2); + t.equal(f({zoom: 2}, {properties: { prop: 1 }}), 4); t.end(); }); @@ -448,9 +448,9 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(1.9, { prop: 1 }), 4); - t.equal(f(2, { prop: 1 }), 6); - t.equal(f(2.1, { prop: 1 }), 8); + t.equal(f({zoom: 1.9}, {properties: { prop: 1 }}), 4); + t.equal(f({zoom: 2}, {properties: { prop: 1 }}), 6); + t.equal(f({zoom: 2.1}, {properties: { prop: 1 }}), 8); t.end(); }); @@ -470,10 +470,10 @@ test('exponential function', (t) => { type: 'number' }); - t.equal(f(1, { prop: 0 }), 0); - t.equal(f(1.5, { prop: 0 }), 1); - t.equal(f(2, { prop: 0 }), 10); - t.equal(f(2.5, { prop: 0 }), 20); + t.equal(f({zoom: 1}, {properties: { prop: 0 }}), 0); + t.equal(f({zoom: 1.5}, {properties: { prop: 0 }}), 1); + t.equal(f({zoom: 2}, {properties: { prop: 0 }}), 10); + t.equal(f({zoom: 2.5}, {properties: { prop: 0 }}), 20); t.end(); }); @@ -494,9 +494,9 @@ test('exponential function', (t) => { type: 'color' }); - t.equal(f(0, {}), undefined); - t.equal(f(0.5, {}), undefined); - t.equal(f(1, {}), undefined); + t.equal(f({zoom: 0}, {properties: {}}), undefined); + t.equal(f({zoom: 0.5}, {properties: {}}), undefined); + t.equal(f({zoom: 1}, {properties: {}}), undefined); t.end(); }); @@ -513,10 +513,10 @@ test('interval function', (t) => { function: 'piecewise-constant' }); - t.equal(f(-1.5), 11); - t.equal(f(-0.5), 11); - t.equal(f(0), 111); - t.equal(f(0.5), 111); + t.equal(f({zoom: -1.5}), 11); + t.equal(f({zoom: -0.5}), 11); + t.equal(f({zoom: 0}), 111); + t.equal(f({zoom: 0.5}), 111); t.end(); }); @@ -529,9 +529,9 @@ test('interval function', (t) => { type: 'number' }); - t.equal(f(-0.5), 11); - t.equal(f(0), 11); - t.equal(f(0.5), 11); + t.equal(f({zoom: -0.5}), 11); + t.equal(f({zoom: 0}), 11); + t.equal(f({zoom: 0.5}), 11); t.end(); }); @@ -544,10 +544,10 @@ test('interval function', (t) => { type: 'number' }); - t.equal(f(-1.5), 11); - t.equal(f(-0.5), 11); - t.equal(f(0), 111); - t.equal(f(0.5), 111); + t.equal(f({zoom: -1.5}), 11); + t.equal(f({zoom: -0.5}), 11); + t.equal(f({zoom: 0}), 111); + t.equal(f({zoom: 0.5}), 111); t.end(); }); @@ -560,12 +560,12 @@ test('interval function', (t) => { type: 'number' }); - t.equal(f(-1.5), 11); - t.equal(f(-0.5), 11); - t.equal(f(0), 111); - t.equal(f(0.5), 111); - t.equal(f(1), 1111); - t.equal(f(1.5), 1111); + t.equal(f({zoom: -1.5}), 11); + t.equal(f({zoom: -0.5}), 11); + t.equal(f({zoom: 0}), 111); + t.equal(f({zoom: 0.5}), 111); + t.equal(f({zoom: 1}), 1111); + t.equal(f({zoom: 1.5}), 1111); t.end(); }); @@ -578,14 +578,14 @@ test('interval function', (t) => { type: 'number' }); - t.equal(f(-1.5), 11); - t.equal(f(-0.5), 11); - t.equal(f(0), 111); - t.equal(f(0.5), 111); - t.equal(f(1), 1111); - t.equal(f(1.5), 1111); - t.equal(f(2), 11111); - t.equal(f(2.5), 11111); + t.equal(f({zoom: -1.5}), 11); + t.equal(f({zoom: -0.5}), 11); + t.equal(f({zoom: 0}), 111); + t.equal(f({zoom: 0.5}), 111); + t.equal(f({zoom: 1}), 1111); + t.equal(f({zoom: 1.5}), 1111); + t.equal(f({zoom: 2}), 11111); + t.equal(f({zoom: 2.5}), 11111); t.end(); }); @@ -598,9 +598,9 @@ test('interval function', (t) => { type: 'color' }); - t.deepEqual(f(0), [1, 0, 0, 1]); - t.deepEqual(f(0), [1, 0, 0, 1]); - t.deepEqual(f(11), [0, 0, 1, 1]); + t.deepEqual(f({zoom: 0}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 0}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 11}), [0, 0, 1, 1]); t.end(); }); @@ -614,7 +614,7 @@ test('interval function', (t) => { type: 'string' }); - t.equal(f(0, {foo: 1.5}), 'good'); + t.equal(f({zoom: 0}, {properties: {foo: 1.5}}), 'good'); t.end(); }); @@ -629,7 +629,7 @@ test('interval function', (t) => { type: 'string' }); - t.equal(f(0, {}), 'default'); + t.equal(f({zoom: 0}, {properties: {}}), 'default'); t.end(); }); @@ -644,7 +644,7 @@ test('interval function', (t) => { default: 'default' }); - t.equal(f(0, {}), 'default'); + t.equal(f({zoom: 0}, {properties: {}}), 'default'); t.end(); }); @@ -659,7 +659,7 @@ test('interval function', (t) => { type: 'string' }); - t.equal(f(0, {foo: 'string'}), 'default'); + t.equal(f({zoom: 0}, {properties: {foo: 'string'}}), 'default'); t.end(); }); @@ -674,7 +674,7 @@ test('interval function', (t) => { default: 'default' }); - t.equal(f(0, {foo: 'string'}), 'default'); + t.equal(f({zoom: 0}, {properties: {foo: 'string'}}), 'default'); t.end(); }); @@ -692,9 +692,9 @@ test('categorical function', (t) => { type: 'string' }); - t.equal(f(0, {foo: 0}), 'bad'); - t.equal(f(0, {foo: 1}), 'good'); - t.equal(f(0, {foo: 2}), 'bad'); + t.equal(f({zoom: 0}, {properties: {foo: 0}}), 'bad'); + t.equal(f({zoom: 0}, {properties: {foo: 1}}), 'good'); + t.equal(f({zoom: 0}, {properties: {foo: 2}}), 'bad'); t.end(); }); @@ -709,8 +709,8 @@ test('categorical function', (t) => { type: 'string' }); - t.equal(f(0, {}), 'default'); - t.equal(f(0, {foo: 3}), 'default'); + t.equal(f({zoom: 0}, {properties: {}}), 'default'); + t.equal(f({zoom: 0}, {properties: {foo: 3}}), 'default'); t.end(); }); @@ -722,12 +722,13 @@ test('categorical function', (t) => { stops: [[{zoom: 0, value: 'bar'}, 'zero']], default: 'default' }, { - type: 'string' + type: 'string', + function: 'interval' }); - t.equal(f(0, {}), 'default'); - t.equal(f(0, {foo: 3}), 'default'); - t.equal(f(0, {foo: 'bar'}), 'zero'); + t.equal(f({zoom: 0}, {properties: {}}), 'default'); + t.equal(f({zoom: 0}, {properties: {foo: 3}}), 'default'); + t.equal(f({zoom: 0}, {properties: {foo: 'bar'}}), 'zero'); t.end(); }); @@ -775,8 +776,8 @@ test('categorical function', (t) => { default: 'default' }); - t.equal(f(0, {}), 'default'); - t.equal(f(0, {foo: 3}), 'default'); + t.equal(f({zoom: 0}, {properties: {}}), 'default'); + t.equal(f({zoom: 0}, {properties: {foo: 3}}), 'default'); t.end(); }); @@ -790,8 +791,8 @@ test('categorical function', (t) => { type: 'color' }); - t.deepEqual(f(0, {foo: 0}), [1, 0, 0, 1]); - t.deepEqual(f(1, {foo: 1}), [0, 0, 1, 1]); + t.deepEqual(f({zoom: 0}, {properties: {foo: 0}}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 1}, {properties: {foo: 1}}), [0, 0, 1, 1]); t.end(); }); @@ -806,8 +807,8 @@ test('categorical function', (t) => { type: 'color' }); - t.deepEqual(f(0, {}), [0, 1, 0, 1]); - t.deepEqual(f(0, {foo: 3}), [0, 1, 0, 1]); + t.deepEqual(f({zoom: 0}, {properties: {}}), [0, 1, 0, 1]); + t.deepEqual(f({zoom: 0}, {properties: {foo: 3}}), [0, 1, 0, 1]); t.end(); }); @@ -822,8 +823,8 @@ test('categorical function', (t) => { default: 'lime' }); - t.deepEqual(f(0, {}), [0, 1, 0, 1]); - t.deepEqual(f(0, {foo: 3}), [0, 1, 0, 1]); + t.deepEqual(f({zoom: 0}, {properties: {}}), [0, 1, 0, 1]); + t.deepEqual(f({zoom: 0}, {properties: {foo: 3}}), [0, 1, 0, 1]); t.end(); }); @@ -837,8 +838,8 @@ test('categorical function', (t) => { type: 'string' }); - t.equal(f(0, {foo: true}), 'true'); - t.equal(f(0, {foo: false}), 'false'); + t.equal(f({zoom: 0}, {properties: {foo: true}}), 'true'); + t.equal(f({zoom: 0}, {properties: {foo: false}}), 'false'); t.end(); }); @@ -855,12 +856,12 @@ test('identity function', (t) => { type: 'number' }); - t.equal(f(0, {foo: 1}), 1); + t.equal(f({zoom: 0}, {properties: {foo: 1}}), 1); t.end(); }); - t.test('number function default', (t) => { + t.test('number function default', {skip: true}, (t) => { const f = createFunction({ property: 'foo', type: 'identity', @@ -869,12 +870,12 @@ test('identity function', (t) => { type: 'string' }); - t.equal(f(0, {}), 1); + t.equal(f({zoom: 0}, {properties: {}}), 1); t.end(); }); - t.test('number spec default', (t) => { + t.test('number spec default', {skip: true}, (t) => { const f = createFunction({ property: 'foo', type: 'identity' @@ -883,7 +884,7 @@ test('identity function', (t) => { default: 1 }); - t.equal(f(0, {}), 1); + t.equal(f({zoom: 0}, {properties: {}}), 1); t.end(); }); @@ -896,8 +897,8 @@ test('identity function', (t) => { type: 'color' }); - t.deepEqual(f(0, {foo: 'red'}), [1, 0, 0, 1]); - t.deepEqual(f(1, {foo: 'blue'}), [0, 0, 1, 1]); + t.deepEqual(f({zoom: 0}, {properties: {foo: 'red'}}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 1}, {properties: {foo: 'blue'}}), [0, 0, 1, 1]); t.end(); }); @@ -911,7 +912,7 @@ test('identity function', (t) => { type: 'color' }); - t.deepEqual(f(0, {}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 0}, {properties: {}}), [1, 0, 0, 1]); t.end(); }); @@ -925,7 +926,7 @@ test('identity function', (t) => { default: 'red' }); - t.deepEqual(f(0, {}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 0}, {properties: {}}), [1, 0, 0, 1]); t.end(); }); @@ -939,7 +940,7 @@ test('identity function', (t) => { default: 'red' }); - t.deepEqual(f(0, {foo: 'invalid'}), [1, 0, 0, 1]); + t.deepEqual(f({zoom: 0}, {properties: {foo: 'invalid'}}), [1, 0, 0, 1]); t.end(); }); @@ -953,7 +954,7 @@ test('identity function', (t) => { type: 'string' }); - t.equal(f(0, {foo: 0}), 'default'); + t.equal(f({zoom: 0}, {properties: {foo: 0}}), 'default'); t.end(); }); @@ -967,7 +968,7 @@ test('identity function', (t) => { default: 'default' }); - t.equal(f(0, {foo: 0}), 'default'); + t.equal(f({zoom: 0}, {properties: {foo: 0}}), 'default'); t.end(); }); @@ -984,7 +985,7 @@ test('identity function', (t) => { default: 'def' }); - t.equal(f(0, {foo: 'bar'}), 'bar'); + t.equal(f({zoom: 0}, {properties: {foo: 'bar'}}), 'bar'); t.end(); }); @@ -1001,7 +1002,7 @@ test('identity function', (t) => { default: 'def' }); - t.equal(f(0, {foo: 'baz'}), 'def'); + t.equal(f({zoom: 0}, {properties: {foo: 'baz'}}), 'def'); t.end(); }); @@ -1018,7 +1019,7 @@ test('identity function', (t) => { default: 'def' }); - t.equal(f(0, {foo: 3}), 'def'); + t.equal(f({zoom: 0}, {properties: {foo: 3}}), 'def'); t.end(); }); @@ -1031,14 +1032,14 @@ test('unknown function', (t) => { type: 'nonesuch', stops: [[]] }, { type: 'string' - }), /Unknown function type "nonesuch"/); + }), /Unknown zoom function type "nonesuch"/); t.end(); }); test('isConstant', (t) => { t.test('constant', (t) => { const f = createFunction(1, { - type: 'string' + type: 'number' }); t.ok(f.isZoomConstant); @@ -1051,7 +1052,7 @@ test('isConstant', (t) => { const f = createFunction({ stops: [[1, 1]] }, { - type: 'string' + type: 'number' }); t.notOk(f.isZoomConstant); @@ -1065,7 +1066,7 @@ test('isConstant', (t) => { stops: [[1, 1]], property: 'mapbox' }, { - type: 'string' + type: 'number' }); t.ok(f.isZoomConstant); @@ -1076,10 +1077,10 @@ test('isConstant', (t) => { t.test('zoom + property', (t) => { const f = createFunction({ - stops: [[{ zoom: 1, data: 1 }, 1]], + stops: [[{ zoom: 1, value: 1 }, 1]], property: 'mapbox' }, { - type: 'string' + type: 'number' }); t.notOk(f.isZoomConstant); diff --git a/yarn.lock b/yarn.lock index ede06505142..57c8d1105ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3774,6 +3774,10 @@ json-stable-stringify@~0.0.0: dependencies: jsonify "~0.0.0" +json-stringify-pretty-compact@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-1.0.4.tgz#d5161131be27fd9748391360597fcca250c6c5ce" + json-stringify-safe@~5.0.0, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"