Merge pull request #10214 from jahow/webgl-layer-boolean-operators

WebGL / Add 'case' operator for style expressions
This commit is contained in:
Andreas Hocevar
2019-10-28 21:11:47 +01:00
committed by GitHub
7 changed files with 326 additions and 83 deletions

View File

@@ -18,7 +18,7 @@ const period = 12; // animation period in seconds
const animRatio =
['^',
['/',
['mod',
['%',
['+',
['time'],
[

View File

@@ -20,3 +20,7 @@ cloak:
---
<div id="map" class="map"></div>
<div>Current sighting: <span id="info"></span></div>
<div>
Filter by UFO shape:
<select id="shape-filter"></select>
</div>

View File

@@ -10,6 +10,22 @@ import WebGLPointsLayer from '../src/ol/layer/WebGLPoints.js';
const key = 'pk.eyJ1IjoidHNjaGF1YiIsImEiOiJjaW5zYW5lNHkxMTNmdWttM3JyOHZtMmNtIn0.CDIBD8H-G2Gf-cPkIuWtRg';
const map = new Map({
layers: [
new TileLayer({
source: new TileJSON({
url: 'https://api.tiles.mapbox.com/v4/mapbox.world-dark.json?secure&access_token=' + key,
crossOrigin: 'anonymous'
})
})
],
target: document.getElementById('map'),
view: new View({
center: [0, 4000000],
zoom: 2
})
});
const vectorSource = new Vector({
features: [],
attributions: 'National UFO Reporting Center'
@@ -20,6 +36,15 @@ const newColor = [180, 255, 200];
const size = 16;
const style = {
variables: {
filterShape: 'all'
},
filter: [
'case',
['!=', ['var', 'filterShape'], 'all'],
['==', ['get', 'shape'], ['var', 'filterShape']],
true
],
symbol: {
symbolType: 'image',
src: 'data/ufo_shapes.png',
@@ -51,61 +76,71 @@ const style = {
}
};
function loadData() {
const client = new XMLHttpRequest();
client.open('GET', 'data/csv/ufo_sighting_data.csv');
client.onload = function() {
const csv = client.responseText;
const features = [];
let prevIndex = csv.indexOf('\n') + 1; // scan past the header line
let curIndex;
while ((curIndex = csv.indexOf('\n', prevIndex)) != -1) {
const line = csv.substr(prevIndex, curIndex - prevIndex).split(',');
prevIndex = curIndex + 1;
const coords = fromLonLat([parseFloat(line[5]), parseFloat(line[4])]);
// only keep valid points
if (isNaN(coords[0]) || isNaN(coords[1])) {
continue;
}
features.push(new Feature({
datetime: line[0],
year: parseInt(/[0-9]{4}/.exec(line[0])[0]), // extract the year as int
shape: line[2],
duration: line[3],
geometry: new Point(coords)
}));
}
vectorSource.addFeatures(features);
};
client.send();
// key is shape name, value is sightings count
const shapeTypes = {
all: 0
};
const shapeSelect = document.getElementById('shape-filter');
shapeSelect.addEventListener('input', function() {
style.variables.filterShape = shapeSelect.options[shapeSelect.selectedIndex].value;
map.render();
});
function fillShapeSelect() {
Object.keys(shapeTypes)
.sort(function(a, b) {
return shapeTypes[b] - shapeTypes[a];
})
.forEach(function(shape) {
const option = document.createElement('option');
option.text = `${shape} (${shapeTypes[shape]} sightings)`;
option.value = shape;
shapeSelect.appendChild(option);
});
}
loadData();
const client = new XMLHttpRequest();
client.open('GET', 'data/csv/ufo_sighting_data.csv');
client.onload = function() {
const csv = client.responseText;
const features = [];
const map = new Map({
layers: [
new TileLayer({
source: new TileJSON({
url: 'https://api.tiles.mapbox.com/v4/mapbox.world-dark.json?secure&access_token=' + key,
crossOrigin: 'anonymous'
})
}),
new WebGLPointsLayer({
source: vectorSource,
style: style
})
],
target: document.getElementById('map'),
view: new View({
center: [0, 4000000],
zoom: 2
let prevIndex = csv.indexOf('\n') + 1; // scan past the header line
let curIndex;
while ((curIndex = csv.indexOf('\n', prevIndex)) != -1) {
const line = csv.substr(prevIndex, curIndex - prevIndex).split(',');
prevIndex = curIndex + 1;
const coords = fromLonLat([parseFloat(line[5]), parseFloat(line[4])]);
// only keep valid points
if (isNaN(coords[0]) || isNaN(coords[1])) {
continue;
}
const shape = line[2];
shapeTypes[shape] = (shapeTypes[shape] ? shapeTypes[shape] : 0) + 1;
shapeTypes['all']++;
features.push(new Feature({
datetime: line[0],
year: parseInt(/[0-9]{4}/.exec(line[0])[0]), // extract the year as int
shape: shape,
duration: line[3],
geometry: new Point(coords)
}));
}
vectorSource.addFeatures(features);
fillShapeSelect();
};
client.send();
map.addLayer(
new WebGLPointsLayer({
source: vectorSource,
style: style
})
});
);
const info = document.getElementById('info');
map.on('pointermove', function(evt) {

View File

@@ -23,10 +23,13 @@ import {asArray, isStringColor} from '../color.js';
* * `['+', value1, value1]` adds `value1` and `value2`
* * `['-', value1, value1]` subtracts `value2` from `value1`
* * `['clamp', value, low, high]` clamps `value` between `low` and `high`
* * `['mod', value1, value1]` returns the result of `value1 % value2` (modulo)
* * `['%', value1, value1]` returns the result of `value1 % value2` (modulo)
* * `['^', value1, value1]` returns the value of `value1` raised to the `value2` power
*
* * Transform operators:
* * `['case', condition1, output1, ...conditionN, outputN, fallback]` selects the first output whose corresponding
* condition evaluates to `true`. If no match is found, returns the `fallback` value.
* All conditions should be `boolean`, output and fallback can be any kind.
* * `['match', input, match1, output1, ...matchN, outputN, fallback]` compares the `input` value against all
* provided `matchX` values, returning the output associated with the first valid match. If no match is found,
* returns the `fallback` value.
@@ -41,14 +44,15 @@ import {asArray, isStringColor} from '../color.js';
* between `output1` and `outputN`.
*
* * Logical operators:
* * `['<', value1, value2]` returns `1` if `value1` is strictly lower than value 2, or `0` otherwise.
* * `['<=', value1, value2]` returns `1` if `value1` is lower than or equals value 2, or `0` otherwise.
* * `['>', value1, value2]` returns `1` if `value1` is strictly greater than value 2, or `0` otherwise.
* * `['>=', value1, value2]` returns `1` if `value1` is greater than or equals value 2, or `0` otherwise.
* * `['==', value1, value2]` returns `1` if `value1` equals value 2, or `0` otherwise.
* * `['!', value1]` returns `0` if `value1` strictly greater than `0`, or `1` otherwise.
* * `['between', value1, value2, value3]` returns `1` if `value1` is contained between `value2` and `value3`
* (inclusively), or `0` otherwise.
* * `['<', value1, value2]` returns `true` if `value1` is strictly lower than value 2, or `false` otherwise.
* * `['<=', value1, value2]` returns `true` if `value1` is lower than or equals value 2, or `false` otherwise.
* * `['>', value1, value2]` returns `true` if `value1` is strictly greater than value 2, or `false` otherwise.
* * `['>=', value1, value2]` returns `true` if `value1` is greater than or equals value 2, or `false` otherwise.
* * `['==', value1, value2]` returns `true` if `value1` equals value 2, or `false` otherwise.
* * `['!=', value1, value2]` returns `true` if `value1` equals value 2, or `false` otherwise.
* * `['!', value1]` returns `false` if `value1` is `true` or greater than `0`, or `true` otherwise.
* * `['between', value1, value2, value3]` returns `true` if `value1` is contained between `value2` and `value3`
* (inclusively), or `false` otherwise.
*
* * Conversion operators:
* * `['array', value1, ...valueN]` creates a numerical array from `number` values; please note that the amount of
@@ -59,6 +63,7 @@ import {asArray, isStringColor} from '../color.js';
*
* Values can either be literals or another operator, as they will be evaluated recursively.
* Literal values can be of the following types:
* * `boolean`
* * `number`
* * `string`
* * {@link module:ol/color~Color}
@@ -252,9 +257,9 @@ function assertNumber(value) {
throw new Error(`A numeric value was expected, got ${JSON.stringify(value)} instead`);
}
}
function assertNumbers(arr) {
for (let i = 0; i < arr.length; i++) {
assertNumber(arr[i]);
function assertNumbers(values) {
for (let i = 0; i < values.length; i++) {
assertNumber(values[i]);
}
}
function assertString(value) {
@@ -287,6 +292,11 @@ function assertArgsEven(args) {
throw new Error(`An even amount of arguments was expected, got ${args} instead`);
}
}
function assertArgsOdd(args) {
if (args.length % 2 === 0) {
throw new Error(`An even amount of arguments was expected, got ${args} instead`);
}
}
function assertUniqueInferredType(args, types) {
if (!isTypeUnique(types)) {
throw new Error(`Could not infer only one type from the following expression: ${JSON.stringify(args)}`);
@@ -349,6 +359,7 @@ Operators['resolution'] = {
return 'u_resolution';
}
};
Operators['*'] = {
getReturnType: function(args) {
return ValueTypes.NUMBER;
@@ -401,7 +412,7 @@ Operators['clamp'] = {
return `clamp(${expressionToGlsl(context, args[0])}, ${min}, ${max})`;
}
};
Operators['mod'] = {
Operators['%'] = {
getReturnType: function(args) {
return ValueTypes.NUMBER;
},
@@ -421,6 +432,7 @@ Operators['^'] = {
return `pow(${expressionToGlsl(context, args[0])}, ${expressionToGlsl(context, args[1])})`;
}
};
Operators['>'] = {
getReturnType: function(args) {
return ValueTypes.BOOLEAN;
@@ -461,16 +473,31 @@ Operators['<='] = {
return `(${expressionToGlsl(context, args[0])} <= ${expressionToGlsl(context, args[1])})`;
}
};
Operators['=='] = {
getReturnType: function(args) {
return ValueTypes.BOOLEAN;
},
toGlsl: function(context, args) {
assertArgsCount(args, 2);
assertNumbers(args);
return `(${expressionToGlsl(context, args[0])} == ${expressionToGlsl(context, args[1])})`;
}
};
function getEqualOperator(operator) {
return {
getReturnType: function(args) {
return ValueTypes.BOOLEAN;
},
toGlsl: function(context, args) {
assertArgsCount(args, 2);
// find common type
let type = ValueTypes.ANY;
for (let i = 0; i < args.length; i++) {
type = type & getValueType(args[i]);
}
if (type === 0) {
throw new Error(`All arguments should be of compatible type, got ${JSON.stringify(args)} instead`);
}
return `(${expressionToGlsl(context, args[0], type)} ${operator} ${expressionToGlsl(context, args[1], type)})`;
}
};
}
Operators['=='] = getEqualOperator('==');
Operators['!='] = getEqualOperator('!=');
Operators['!'] = {
getReturnType: function(args) {
return ValueTypes.BOOLEAN;
@@ -494,6 +521,7 @@ Operators['between'] = {
return `(${value} >= ${min} && ${value} <= ${max})`;
}
};
Operators['array'] = {
getReturnType: function(args) {
return ValueTypes.NUMBER_ARRAY;
@@ -526,6 +554,7 @@ Operators['color'] = {
return `vec${args.length}(${parsedArgs.join(', ')})`;
}
};
Operators['interpolate'] = {
getReturnType: function(args) {
let type = ValueTypes.COLOR | ValueTypes.NUMBER;
@@ -580,7 +609,6 @@ Operators['match'] = {
assertArgsEven(args);
assertArgsMinCount(args, 4);
// compute input/output types
const typeHint = opt_typeHint !== undefined ? opt_typeHint : ValueTypes.ANY;
const outputType = Operators['match'].getReturnType(args) & typeHint;
assertUniqueInferredType(args, outputType);
@@ -596,3 +624,33 @@ Operators['match'] = {
return result;
}
};
Operators['case'] = {
getReturnType: function(args) {
let type = ValueTypes.ANY;
for (let i = 1; i < args.length; i += 2) {
type = type & getValueType(args[i]);
}
type = type & getValueType(args[args.length - 1]);
return type;
},
toGlsl: function(context, args, opt_typeHint) {
assertArgsOdd(args);
assertArgsMinCount(args, 3);
const typeHint = opt_typeHint !== undefined ? opt_typeHint : ValueTypes.ANY;
const outputType = Operators['case'].getReturnType(args) & typeHint;
assertUniqueInferredType(args, outputType);
for (let i = 0; i < args.length - 1; i += 2) {
assertBoolean(args[i]);
}
const fallback = expressionToGlsl(context, args[args.length - 1], outputType);
let result = null;
for (let i = args.length - 3; i >= 0; i -= 2) {
const condition = expressionToGlsl(context, args[i]);
const output = expressionToGlsl(context, args[i + 1], outputType);
result = `(${condition} ? ${output} : ${result || fallback})`;
}
return result;
}
};

View File

@@ -3,7 +3,7 @@
* @module ol/webgl/ShaderBuilder
*/
import {expressionToGlsl, ValueTypes} from '../style/expressions.js';
import {expressionToGlsl, stringToGlsl, ValueTypes} from '../style/expressions.js';
/**
* @typedef {Object} VaryingDescription
@@ -445,8 +445,14 @@ export function parseLiteralStyle(style) {
fragContext.variables.forEach(function(varName) {
builder.addUniform(`float u_${varName}`);
uniforms[`u_${varName}`] = function() {
return style.variables && style.variables[varName] !== undefined ?
style.variables[varName] : 0;
if (!style.variables || style.variables[varName] === undefined) {
throw new Error(`The following variable is missing from the style: ${varName}`);
}
let value = style.variables[varName];
if (typeof value === 'string') {
value = parseFloat(stringToGlsl(vertContext, value));
}
return value !== undefined ? value : -9999999; // to avoid matching with the first string literal
};
});
@@ -481,7 +487,7 @@ export function parseLiteralStyle(style) {
callback: function(feature) {
let value = feature.get(attributeName);
if (typeof value === 'string') {
value = vertContext.stringLiteralsMap[value];
value = parseFloat(stringToGlsl(vertContext, value));
}
return value !== undefined ? value : -9999999; // to avoid matching with the first string literal
}

View File

@@ -130,12 +130,13 @@ describe('ol.style.expressions', function() {
expect(getValueType(['*', ['get', 'size'], 12])).to.eql(ValueTypes.NUMBER);
expect(getValueType(['clamp', ['get', 'attr2'], ['get', 'attr3'], 20])).to.eql(ValueTypes.NUMBER);
expect(getValueType(['^', 10, 2])).to.eql(ValueTypes.NUMBER);
expect(getValueType(['mod', ['time'], 10])).to.eql(ValueTypes.NUMBER);
expect(getValueType(['%', ['time'], 10])).to.eql(ValueTypes.NUMBER);
expect(getValueType(['>', 10, ['get', 'attr4']])).to.eql(ValueTypes.BOOLEAN);
expect(getValueType(['>=', 10, ['get', 'attr4']])).to.eql(ValueTypes.BOOLEAN);
expect(getValueType(['<', 10, ['get', 'attr4']])).to.eql(ValueTypes.BOOLEAN);
expect(getValueType(['<=', 10, ['get', 'attr4']])).to.eql(ValueTypes.BOOLEAN);
expect(getValueType(['==', 10, ['get', 'attr4']])).to.eql(ValueTypes.BOOLEAN);
expect(getValueType(['!=', 10, ['get', 'attr4']])).to.eql(ValueTypes.BOOLEAN);
expect(getValueType(['between', ['get', 'attr4'], -4.0, 5.0])).to.eql(ValueTypes.BOOLEAN);
expect(getValueType(['!', ['get', 'attr4']])).to.eql(ValueTypes.BOOLEAN);
expect(getValueType(['array', ['get', 'attr4'], 1, 2, 3])).to.eql(ValueTypes.NUMBER_ARRAY);
@@ -164,12 +165,13 @@ describe('ol.style.expressions', function() {
expect(expressionToGlsl(context, ['+', ['*', ['get', 'size'], 0.001], 12])).to.eql('((a_size * 0.001) + 12.0)');
expect(expressionToGlsl(context, ['/', ['-', ['get', 'size'], 20], 100])).to.eql('((a_size - 20.0) / 100.0)');
expect(expressionToGlsl(context, ['clamp', ['get', 'attr2'], ['get', 'attr3'], 20])).to.eql('clamp(a_attr2, a_attr3, 20.0)');
expect(expressionToGlsl(context, ['^', ['mod', ['time'], 10], 2])).to.eql('pow(mod(u_time, 10.0), 2.0)');
expect(expressionToGlsl(context, ['^', ['%', ['time'], 10], 2])).to.eql('pow(mod(u_time, 10.0), 2.0)');
expect(expressionToGlsl(context, ['>', 10, ['get', 'attr4']])).to.eql('(10.0 > a_attr4)');
expect(expressionToGlsl(context, ['>=', 10, ['get', 'attr4']])).to.eql('(10.0 >= a_attr4)');
expect(expressionToGlsl(context, ['<', 10, ['get', 'attr4']])).to.eql('(10.0 < a_attr4)');
expect(expressionToGlsl(context, ['<=', 10, ['get', 'attr4']])).to.eql('(10.0 <= a_attr4)');
expect(expressionToGlsl(context, ['==', 10, ['get', 'attr4']])).to.eql('(10.0 == a_attr4)');
expect(expressionToGlsl(context, ['!=', 10, ['get', 'attr4']])).to.eql('(10.0 != a_attr4)');
expect(expressionToGlsl(context, ['between', ['get', 'attr4'], -4.0, 5.0])).to.eql('(a_attr4 >= -4.0 && a_attr4 <= 5.0)');
expect(expressionToGlsl(context, ['!', ['get', 'attr4']])).to.eql('(!a_attr4)');
expect(expressionToGlsl(context, ['array', ['get', 'attr4'], 1, 2, 3])).to.eql('vec4(a_attr4, 1.0, 2.0, 3.0)');
@@ -292,6 +294,94 @@ describe('ol.style.expressions', function() {
});
});
describe('case operator', function() {
let context;
beforeEach(function() {
context = {
variables: [],
attributes: [],
stringLiteralsMap: {}
};
});
it('correctly guesses the output type', function() {
expect(getValueType(['case', true, 0, false, [3, 4, 5], 'green']))
.to.eql(ValueTypes.NONE);
expect(getValueType(['case', true, 0, false, 1, 2]))
.to.eql(ValueTypes.NUMBER);
expect(getValueType(['case', true, [0, 0, 0], true, [1, 2, 3], ['get', 'attr'], [4, 5, 6, 7], [8, 9, 0]]))
.to.eql(ValueTypes.COLOR | ValueTypes.NUMBER_ARRAY);
expect(getValueType(['case', true, 'red', true, 'yellow', ['get', 'attr'], 'green', 'white']))
.to.eql(ValueTypes.COLOR | ValueTypes.STRING);
expect(getValueType(['case', true, [0, 0], false, [1, 1], [2, 2]]))
.to.eql(ValueTypes.NUMBER_ARRAY);
});
it('throws if no single output type could be inferred', function() {
let thrown = false;
try {
expressionToGlsl(context, ['case', false, 'red', true, 'yellow', 'green'], ValueTypes.COLOR);
} catch (e) {
thrown = true;
}
expect(thrown).to.be(false);
try {
expressionToGlsl(context, ['case', true, 'red', true, 'yellow', 'green']);
} catch (e) {
thrown = true;
}
expect(thrown).to.be(true);
thrown = false;
try {
expressionToGlsl(context, ['case', true, 'red', false, 'yellow', 'green'], ValueTypes.NUMBER);
} catch (e) {
thrown = true;
}
expect(thrown).to.be(true);
thrown = false;
try {
expressionToGlsl(context, ['case', true, 'red', false, 'yellow', 'not_a_color'], ValueTypes.COLOR);
} catch (e) {
thrown = true;
}
expect(thrown).to.be(true);
});
it('throws if invalid argument count', function() {
let thrown = false;
try {
expressionToGlsl(context, ['case', true, 0, false, 1]);
} catch (e) {
thrown = true;
}
expect(thrown).to.be(true);
thrown = false;
try {
expressionToGlsl(context, ['case', true, 0]);
} catch (e) {
thrown = true;
}
expect(thrown).to.be(true);
try {
expressionToGlsl(context, ['case', false]);
} catch (e) {
thrown = true;
}
expect(thrown).to.be(true);
});
it('correctly parses the expression (colors)', function() {
expect(expressionToGlsl(context, ['case', ['>', ['get', 'attr'], 3], 'red', ['>', ['get', 'attr'], 1], 'yellow', 'white'], ValueTypes.COLOR))
.to.eql('((a_attr > 3.0) ? vec4(1.0, 0.0, 0.0, 1.0) : ((a_attr > 1.0) ? vec4(1.0, 1.0, 0.0, 1.0) : vec4(1.0, 1.0, 1.0, 1.0)))');
});
});
describe('match operator', function() {
let context;
@@ -529,7 +619,7 @@ describe('ol.style.expressions', function() {
['linear'],
['^',
['/',
['mod',
['%',
['+',
['time'],
[

View File

@@ -402,6 +402,56 @@ void main(void) {
expect(result.attributes).to.eql([]);
expect(result.uniforms).to.have.property('u_ratio');
});
it('correctly adds string variables to the string literals mapping', function() {
const result = parseLiteralStyle({
variables: {
mySize: 'abcdef'
},
symbol: {
symbolType: 'square',
size: ['match', ['var', 'mySize'], 'abc', 10, 'def', 20, 30],
color: 'red'
}
});
expect(result.uniforms['u_mySize']()).to.be.greaterThan(0);
});
it('throws when a variable is requested but not present in the style', function(done) {
const result = parseLiteralStyle({
variables: {},
symbol: {
symbolType: 'square',
size: ['var', 'mySize'],
color: 'red'
}
});
try {
result.uniforms['u_mySize']();
} catch (e) {
done();
}
done(true);
});
it('throws when a variable is requested but the style does not have a variables dict', function(done) {
const result = parseLiteralStyle({
symbol: {
symbolType: 'square',
size: ['var', 'mySize'],
color: 'red'
}
});
try {
result.uniforms['u_mySize']();
} catch (e) {
done();
}
done(true);
});
});
});