Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[for discussion] Draft proposal - arbitrary expressions for style functions #4715

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions debug/expressions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<head>
<title>Mapbox GL JS debug page</title>
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel='stylesheet' href='/dist/mapbox-gl.css' />
<style>
body { margin: 0; padding: 0; }
html, body, #map { height: 100%; }
</style>
</head>

<body>
<div id='map'></div>

<script src='/dist/mapbox-gl-dev.js'></script>
<script src='/debug/access_token_generated.js'></script>
<script>

var style = {
sprite: "mapbox://sprites/mapbox/streets-v10",
glyphs: "mapbox://fonts/mapbox/{fontstack}/{range}.pbf",
sources: {
"geojson": {
"type": "geojson",
"data": {
"type": "FeatureCollection",
"features": [{"type":"Feature","properties":{"x":-10,"y":-10},"geometry":{"type":"Point","coordinates":[-10,-10]}},{"type":"Feature","properties":{"x":-10,"y":-6},"geometry":{"type":"Point","coordinates":[-10,-6]}},{"type":"Feature","properties":{"x":-10,"y":-2},"geometry":{"type":"Point","coordinates":[-10,-2]}},{"type":"Feature","properties":{"x":-10,"y":2},"geometry":{"type":"Point","coordinates":[-10,2]}},{"type":"Feature","properties":{"x":-10,"y":6},"geometry":{"type":"Point","coordinates":[-10,6]}},{"type":"Feature","properties":{"x":-10,"y":10},"geometry":{"type":"Point","coordinates":[-10,10]}},{"type":"Feature","properties":{"x":-6,"y":-10},"geometry":{"type":"Point","coordinates":[-6,-10]}},{"type":"Feature","properties":{"x":-6,"y":-6},"geometry":{"type":"Point","coordinates":[-6,-6]}},{"type":"Feature","properties":{"x":-6,"y":-2},"geometry":{"type":"Point","coordinates":[-6,-2]}},{"type":"Feature","properties":{"x":-6,"y":2},"geometry":{"type":"Point","coordinates":[-6,2]}},{"type":"Feature","properties":{"x":-6,"y":6},"geometry":{"type":"Point","coordinates":[-6,6]}},{"type":"Feature","properties":{"x":-6,"y":10},"geometry":{"type":"Point","coordinates":[-6,10]}},{"type":"Feature","properties":{"x":-2,"y":-10},"geometry":{"type":"Point","coordinates":[-2,-10]}},{"type":"Feature","properties":{"x":-2,"y":-6},"geometry":{"type":"Point","coordinates":[-2,-6]}},{"type":"Feature","properties":{"x":-2,"y":-2},"geometry":{"type":"Point","coordinates":[-2,-2]}},{"type":"Feature","properties":{"x":-2,"y":2},"geometry":{"type":"Point","coordinates":[-2,2]}},{"type":"Feature","properties":{"x":-2,"y":6},"geometry":{"type":"Point","coordinates":[-2,6]}},{"type":"Feature","properties":{"x":-2,"y":10},"geometry":{"type":"Point","coordinates":[-2,10]}},{"type":"Feature","properties":{"x":2,"y":-10},"geometry":{"type":"Point","coordinates":[2,-10]}},{"type":"Feature","properties":{"x":2,"y":-6},"geometry":{"type":"Point","coordinates":[2,-6]}},{"type":"Feature","properties":{"x":2,"y":-2},"geometry":{"type":"Point","coordinates":[2,-2]}},{"type":"Feature","properties":{"x":2,"y":2},"geometry":{"type":"Point","coordinates":[2,2]}},{"type":"Feature","properties":{"x":2,"y":6},"geometry":{"type":"Point","coordinates":[2,6]}},{"type":"Feature","properties":{"x":2,"y":10},"geometry":{"type":"Point","coordinates":[2,10]}},{"type":"Feature","properties":{"x":6,"y":-10},"geometry":{"type":"Point","coordinates":[6,-10]}},{"type":"Feature","properties":{"x":6,"y":-6},"geometry":{"type":"Point","coordinates":[6,-6]}},{"type":"Feature","properties":{"x":6,"y":-2},"geometry":{"type":"Point","coordinates":[6,-2]}},{"type":"Feature","properties":{"x":6,"y":2},"geometry":{"type":"Point","coordinates":[6,2]}},{"type":"Feature","properties":{"x":6,"y":6},"geometry":{"type":"Point","coordinates":[6,6]}},{"type":"Feature","properties":{"x":6,"y":10},"geometry":{"type":"Point","coordinates":[6,10]}},{"type":"Feature","properties":{"x":10,"y":-10},"geometry":{"type":"Point","coordinates":[10,-10]}},{"type":"Feature","properties":{"x":10,"y":-6},"geometry":{"type":"Point","coordinates":[10,-6]}},{"type":"Feature","properties":{"x":10,"y":-2},"geometry":{"type":"Point","coordinates":[10,-2]}},{"type":"Feature","properties":{"x":10,"y":2},"geometry":{"type":"Point","coordinates":[10,2]}},{"type":"Feature","properties":{"x":10,"y":6},"geometry":{"type":"Point","coordinates":[10,6]}},{"type":"Feature","properties":{"x":10,"y":10},"geometry":{"type":"Point","coordinates":[10,10]}}]
}
}
},
layers: [
{
id: 'circle',
type: 'circle',
source: 'geojson',
paint: {
'circle-radius': {
type: 'expression',
expression: [
'*',
[ '^', 2, {ref: 'map', key: 'zoom'} ],
0.125,
[
'+',
20,
{ref: 'feature', key: 'x'},
{ref: 'feature', key: 'y'}
]
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To illustrate the advantages of using NSExpression in the iOS/macOS SDKs, this paint property would be set using the following line of code (Objective-C then Swift):

circleLayer.circleRadius = [NSExpression expressionWithFormat:@"map.zoom**2 * 0.125 * (20 + feature.x + feature.y)"];
circleLayer.circleRadius = NSExpression(format: "map.zoom**2 * 0.125 * (20 + feature.x + feature.y)")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's so nice! What do map.zoom, feature.x, etc. end up looking like in the resulting NSExpression object?

Copy link
Contributor

@1ec5 1ec5 May 18, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initialized NSExpression consists of the following object tree:

  • NSExpression representing the function multiply:by:, the operand _NSPredicateUtilities, and the following NSExpressions as arguments:
    • NSExpression representing the function multiply:by:, the operand _NSPredicateUtilities, and the following NSExpressions as arguments:
      • NSExpression representing the function raise:toPower:, the operand _NSPredicateUtilities, and the following NSExpressions as arguments:
        • NSExpression representing the key path map.zoom (as a string)
        • NSExpression representing the constant value 2
      • NSExpression representing the constant value 0.125
  • etc.

NSExpression comes with a built-in way to evaluate itself given a context dictionary. However, imperative filters aren’t on the roadmap – mapbox/mapbox-gl-native#7860 (comment) – so the iOS/macOS SDK instead translates the AST provided by NSExpression into the AST expected by mbgl.

},
'circle-opacity': {
type: 'expression',
expression: [ '/', [ '+', 10, {ref: 'feature', key: 'x'} ], 30 ]
},
'circle-color': {
type: 'expression',
expression: [
'concat',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concatenating strings to form a color would be problematic for all the native SDKs, where colors are formed using objects rather than CSS color strings. For example, on iOS, a color-typed property must be set to something that evaluates to a UIColor object, not a string. Setting a constant color might look like this:

circleLayer.circleColor = NSExpression(constantValue: UIColor.orange)

If the JSON file format would instead treat rgb et al. as expression functions in their own right, as opposed to the products of string concatenation, it would be possible for the SDK to define its own custom function based on +[UIColor colorWithRed:green:blue:alpha:]:

circleLayer.circleColor = NSExpression(format: "mgl_colorWithRedGreenBlueAlpha({ 128 + 10 * feature.x, 128 + 10 * feature.y, 128, 1 })")

perhaps with a more memorable convenience method, +[NSExpression expressionWithMGLRedExpression:greenExpression:blueExpression:alphaExpression]:

let redExpression = NSExpression(format: "128 + 10 * feature.x")
let greenExpression = NSExpression(format: "128 + 10 * feature.y")
let blueExpression = NSExpression(constantValue: 128)
circleLayer.circleColor = NSExpression(mglRed: redExpression, green: greenExpression, blue: blueExpression)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 yep, agreed that having color functions rgb, etc. as opposed to using string concatenation. (Ref: #4715 (comment))

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSS 4 color-modifying function draft might serve as inspiration here: https://drafts.csswg.org/css-color/#modifying-colors

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Woah. That's pretty awesome, although including "color adjuster" functions would probably require taking on a dependency like https://github.com/gka/chroma.js/.

For the scope of this PR, I'm inclined to stick to color constructors, and to ticket/discuss separately the possibility of adding more sophisticated color operations.

'rgb(',
[ '+', 128, [ '*', 10, {ref: 'feature', key: 'x'} ] ],
',',
[ '+', 128, [ '*', 10, {ref: 'feature', key: 'y'} ] ],
',',
128,
')'
]
}
}
}
],
version: 8
}

var map = window.map = new mapboxgl.Map({
container: 'map',
zoom: 0,
center: [0, 0],
style: style,
hash: true
});

</script>
</body>
</html>
42 changes: 42 additions & 0 deletions docs/style-spec/expressions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Style Function Expressions

**Constants:**
- `[ "ln2" ]`
- `[ "pi" ]`
- `[ "e" ]`

**Literal:**
- JSON string / number / boolean literal

**Property lookup:**
- Feature property: `[ "data", key_expr ]`
- Map property:
- `[ "zoom" ]` (Note: expressions that refer to the map zoom level are only evaluated at integer zoomslevels. When the map is at non-integer zoom levels, the expression's value will be approximated using linear or exponential interpolation.)
- `[ "pitch" ]`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would feature property lookups require a data function while zoom and pitch be usable independently of a map function, similar to constants? Is it because the set of map properties is finite and well-defined, in contrast to the set of potential feature properties?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it because the set of map properties is finite and well-defined, in contrast to the set of potential feature properties?

^ yeah, that's my thinking. there's no need for it to be [ "map", "zoom" ] when we can simply reserve [ "zoom" ] as a 0-ary function.

- etc.

**Decision:**
- `["if", boolean_expr, expr_if_true, expr_if_false]`

**Boolean:**
- `[ "has", key_expr ]`
- `[ "==", expr1, expr2]`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would the style author specify that a comparison between two strings should be performed case- or diacritic-insensitively (#4136)? Or would we require downcase and a strip-diacritics function to be applied explicitly to either operand?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or would we require downcase and a strip-diacritics function to be applied explicitly to either operand?

This approach sounds good to me...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or would we require downcase and a strip-diacritics function to be applied explicitly to either operand?

I’m not so sure that would be robust enough for internationalization purposes: #4136 (comment).

Relatedly, should == take multiple arguments in order to say, for instance, a == b == c? (If not, that would open the door to using the second argument for folding flags.) If == shouldn’t take multiple arguments, should arithmetic operators like * take multiple arguments?

- `[ ">", lhs_expr, rhs_expr ]` (similar for <, >=, <=)
- `[ "between", value_expr, lower_bound_expr, upper_bound_expr ]`
- `[ "in", value_expr, item1_expr, item2_expr, ... ]`
- `[ "all", boolean_expr1, boolean_expr2, ... ]` (similar for any, none)

**String:**
- `["concat", expr1, expr2, …]`
- `["upcase", string_expr]`, `["downcase", string_expr]`

**Numeric:**
- +, -, \*, /, %, ^ (e.g. `["+", expr1, expr2, expr3, …]`, `["-", expr1, expr2 ]`, etc.)
- log10, ln, log2
- sin, cos, tan, asin, acos, atan
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These trigonometric functions aren’t listed as being built into NSExpression, but we could define our own functions.

- ceil, floor, round, abs
- min, max

**Color:**
- rgb, hsl, hcl, lab, hex, (others?)

101 changes: 101 additions & 0 deletions src/style-spec/function/expression.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
'use strict';

const assert = require('assert');
const createFilter = require('../feature_filter');

module.exports = compileExpression;

function compileExpression(expr) {
const compiled = compile(expr);
const fun = new Function('mapProperties', 'feature', `
var props = (feature && feature.properties || {});
return (${compiled.compiledExpression})
`);
fun.isFeatureConstant = compiled.isFeatureConstant;
fun.isZoomConsant = compiled.isZoomConstant;
return fun;
}

function compile(expr) {
if (
!expr ||
typeof expr === 'string' ||
typeof expr === 'number' ||
typeof expr === 'boolean'
) return {
compiledExpression: JSON.stringify(expr),
isFeatureConstant: true,
isZoomConstant: true
};

if (expr.ref === 'feature') {
return {
compiledExpression: `
${JSON.stringify(expr.key)} in props ?
props[${JSON.stringify(expr.key)}] :
(${JSON.stringify(expr.defaultValue)} || 0)
`,
isFeatureConstant: false,
isZoomConstant: true
};
}

if (expr.ref === 'map') {
return {
compiledExpression: `mapProperties[${JSON.stringify(expr.key)}]`,
isFeatureConstant: true,
isZoomConstant: expr.key !== 'zoom'
};
}

assert(Array.isArray(expr));

const op = expr[0];

// feature filter
if (
op === '==' ||
op === '!=' ||
op === '<' ||
op === '>' ||
op === '<=' ||
op === '>=' ||
op === 'any' ||
op === 'all' ||
op === 'none' ||
op === 'in' ||
op === '!in' ||
op === 'has' ||
op === '!has'
) {
const featureFilter = createFilter(expr);
return {
compiledExpression: `(${featureFilter.toString()})(feature)`,
isFeatureConstant: true,
isZoomConstant: true
};
}

const subExpressions = expr.slice(1);
const args = subExpressions.map(compile).map(s => `(${s.compiledExpression})`);

let compiled;
if (op === 'concat') {
compiled = `[${args.join(',')}].join('')`;
} else if (op === '+' || op === '*') {
compiled = args.join(op);
} else if (op === 'if') {
compiled = `${args[0]} ? ${args[1]} : ${args[2]}`;
} else if (op === '-' || op === '/' || op === '%') {
compiled = `${args[0]} ${op} ${args[1]}`;
} else if (op === '^') {
compiled = `Math.pow(${args[0]}, ${args[1]})`;
}

return {
compiledExpression: compiled,
isFeatureConstant: subExpressions.reduce((memo, e) => memo && e.isFeatureConstant, true),
isZoomConstnat: subExpressions.reduce((memo, e) => memo && e.isZoomConstant, true)
};
}

10 changes: 8 additions & 2 deletions src/style-spec/function/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const parseColor = require('../util/parse_color');
const extend = require('../util/extend');
const getType = require('../util/get_type');
const interpolate = require('../util/interpolate');
const compileExpression = require('./expression');

function identityFunction(x) {
return x;
Expand All @@ -24,7 +25,12 @@ function createFunction(parameters, propertySpec) {
};
fun.isFeatureConstant = true;
fun.isZoomConstant = true;

} else if (parameters.type === 'expression') {
const expressionFunction = compileExpression(parameters.expression);
fun = function(zoom, featureProperties) {
const result = expressionFunction({zoom}, {properties: featureProperties});
return isColor ? parseColor(result) : result;
};
} else {
const zoomAndFeatureDependent = parameters.stops && typeof parameters.stops[0][0] === 'object';
const featureDependent = zoomAndFeatureDependent || parameters.property !== undefined;
Expand Down Expand Up @@ -248,7 +254,7 @@ function findStopLessThanOrEqualTo(stops, input) {
}

function isFunctionDefinition(value) {
return typeof value === 'object' && (value.stops || value.type === 'identity');
return typeof value === 'object' && (value.stops || value.type === 'identity' || value.type === 'expression');
}

/**
Expand Down
7 changes: 7 additions & 0 deletions src/style-spec/reference/v8.json
Original file line number Diff line number Diff line change
Expand Up @@ -1624,6 +1624,10 @@
"doc": "The geometry type for the filter to select."
},
"function": {
"expression": {
"type": "*",
"doc": "A function expression"
},
"stops": {
"type": "array",
"doc": "An array of stops.",
Expand Down Expand Up @@ -1654,6 +1658,9 @@
},
"categorical": {
"doc": "Return the output value of the stop equal to the function input."
},
"expression": {
"doc": "Return the output value of the given function expression."
}
},
"doc": "The interpolation strategy to use in function evaluation.",
Expand Down
2 changes: 1 addition & 1 deletion src/style-spec/validate/validate_function.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ module.exports = function validateFunction(options) {
errors.push(new ValidationError(options.key, options.value, 'missing required property "property"'));
}

if (functionType !== 'identity' && !options.value.stops) {
if (functionType !== 'identity' && functionType !== 'expression' && !options.value.stops) {
errors.push(new ValidationError(options.key, options.value, 'missing required property "stops"'));
}

Expand Down
26 changes: 20 additions & 6 deletions src/style/style_declaration.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,32 @@ class StyleDeclaration {
if (!this.isFeatureConstant && !this.isZoomConstant) {
this.stopZoomLevels = [];
const interpolationAmountStops = [];
for (const stop of this.value.stops) {
const zoom = stop[0].zoom;
if (this.stopZoomLevels.indexOf(zoom) < 0) {
this.stopZoomLevels.push(zoom);
interpolationAmountStops.push([zoom, interpolationAmountStops.length]);

let base;
if (this.value.type === 'expression') {
// generate "pseudo stops" for the function expression at
// integer zoom levels so that we can interpolate the
// render-time value the same way as for stop-based functions.
base = this.value.zoomInterpolationBase || 1;
for (let z = 0; z < 30; z++) {
this.stopZoomLevels.push(z);
interpolationAmountStops.push([z, interpolationAmountStops.length]);
}
} else {
base = this.value.base;
for (const stop of this.value.stops) {
const zoom = stop[0].zoom;
if (this.stopZoomLevels.indexOf(zoom) < 0) {
this.stopZoomLevels.push(zoom);
interpolationAmountStops.push([zoom, interpolationAmountStops.length]);
}
}
}

this._functionInterpolationT = createFunction({
type: 'exponential',
stops: interpolationAmountStops,
base: value.base
base: base
}, {
type: 'number'
});
Expand Down
Loading