@mapwhit/style-expressions
Version:
Process expressions in map style.
382 lines (337 loc) • 12 kB
JavaScript
import assert from 'assert';
import * as properties from '../util/properties.js';
import { error, success } from '../util/result.js';
import CompoundExpression from './compound_expression.js';
import Coalesce from './definitions/coalesce.js';
import GlobalState from './definitions/global_state.js';
import definitions from './definitions/index.js';
import Interpolate from './definitions/interpolate.js';
import Let from './definitions/let.js';
import Step from './definitions/step.js';
import EvaluationContext from './evaluation_context.js';
import * as isConstant from './is_constant.js';
import ParsingContext from './parsing_context.js';
import ParsingError from './parsing_error.js';
import RuntimeError from './runtime_error.js';
export * from '../util/properties.js';
export * from './types/formatted.js';
const { supportsPropertyExpression, supportsZoomExpression, supportsInterpolation } = properties;
import * as colorSpaces from '../util/color_spaces.js';
import * as interpolate from '../util/interpolate.js';
class StyleExpression {
#globalState;
constructor(expression, propertySpec, globalState) {
this.expression = expression;
this._warningHistory = {};
this._evaluator = new EvaluationContext();
this._defaultValue = propertySpec ? getDefaultValue(propertySpec) : null;
this._enumValues = propertySpec?.type === 'enum' ? propertySpec.values : null;
this.#globalState = globalState;
}
evaluateWithoutErrorHandling(globals, feature, featureState) {
if (this.#globalState) {
globals.globalState = this.#globalState;
}
this._evaluator.globals = globals;
this._evaluator.feature = feature;
this._evaluator.featureState = featureState;
return this.expression.evaluate(this._evaluator);
}
evaluate(globals, feature, featureState) {
if (this.#globalState) {
globals.globalState = this.#globalState;
}
this._evaluator.globals = globals;
this._evaluator.feature = feature || null;
this._evaluator.featureState = featureState || null;
try {
const val = this.expression.evaluate(this._evaluator);
if (val === null || val === undefined) {
return this._defaultValue;
}
if (this._enumValues && !this._enumValues.includes(val)) {
throw new RuntimeError(
`Expected value to be one of ${this._enumValues
.map(v => JSON.stringify(v))
.join(', ')}, but found ${JSON.stringify(val)} instead.`
);
}
return val;
} catch (e) {
if (!this._warningHistory[e.message]) {
this._warningHistory[e.message] = true;
if (typeof console !== 'undefined') {
console.warn(e.message);
}
}
return this._defaultValue;
}
}
}
function isExpression(expression) {
return (
Array.isArray(expression) &&
expression.length > 0 &&
typeof expression[0] === 'string' &&
expression[0] in definitions
);
}
/**
* Parse and typecheck the given style spec JSON expression. If
* options.defaultValue is provided, then the resulting StyleExpression's
* `evaluate()` method will handle errors by logging a warning (once per
* message) and returning the default value. Otherwise, it will throw
* evaluation errors.
*
* @private
*/
function createExpression(expression, propertySpec, globalState) {
const parser = new ParsingContext(definitions, [], propertySpec ? getExpectedType(propertySpec) : undefined);
// For string-valued properties, coerce to string at the top level rather than asserting.
const parsed = parser.parse(
expression,
undefined,
undefined,
undefined,
propertySpec?.type === 'string' ? { typeAnnotation: 'coerce' } : undefined
);
if (!parsed) {
assert(parser.errors.length > 0);
return error(parser.errors);
}
return success(new StyleExpression(parsed, propertySpec, globalState));
}
class ZoomConstantExpression {
#globalState;
constructor(kind, expression, globalState) {
this.kind = kind;
this._styleExpression = expression;
this.isStateDependent = kind !== 'constant' && !isConstant.isStateConstant(expression.expression);
this.globalStateRefs = findGlobalStateRefs(expression.expression);
this.#globalState = globalState;
}
evaluateWithoutErrorHandling(globals, feature, featureState) {
if (this.#globalState) {
globals.globalState = this.#globalState;
}
return this._styleExpression.evaluateWithoutErrorHandling(globals, feature, featureState);
}
evaluate(globals, feature, featureState) {
if (this.#globalState) {
globals.globalState = this.#globalState;
}
return this._styleExpression.evaluate(globals, feature, featureState);
}
}
class ZoomDependentExpression {
#globalState;
constructor(kind, expression, zoomCurve, globalState) {
this.kind = kind;
this.zoomStops = zoomCurve.labels;
this._styleExpression = expression;
this.isStateDependent = kind !== 'camera' && !isConstant.isStateConstant(expression.expression);
this.globalStateRefs = findGlobalStateRefs(expression.expression);
this.#globalState = globalState;
if (zoomCurve instanceof Interpolate) {
this._interpolationType = zoomCurve.interpolation;
}
}
evaluateWithoutErrorHandling(globals, feature, featureState) {
if (this.#globalState) {
globals.globalState = this.#globalState;
}
return this._styleExpression.evaluateWithoutErrorHandling(globals, feature, featureState);
}
evaluate(globals, feature, featureState) {
if (this.#globalState) {
globals.globalState = this.#globalState;
}
return this._styleExpression.evaluate(globals, feature, featureState);
}
interpolationFactor(input, lower, upper) {
if (this._interpolationType) {
return Interpolate.interpolationFactor(this._interpolationType, input, lower, upper);
}
return 0;
}
}
function createPropertyExpression(expression, propertySpec, globalState) {
expression = createExpression(expression, propertySpec, globalState);
if (expression.result === 'error') {
return expression;
}
const parsed = expression.value.expression;
const isFeatureConstant = isConstant.isFeatureConstant(parsed);
if (!isFeatureConstant && !supportsPropertyExpression(propertySpec)) {
return error([new ParsingError('', 'data expressions not supported')]);
}
const isZoomConstant = isConstant.isGlobalPropertyConstant(parsed, ['zoom']);
if (!isZoomConstant && !supportsZoomExpression(propertySpec)) {
return error([new ParsingError('', 'zoom expressions not supported')]);
}
const zoomCurve = findZoomCurve(parsed);
if (!zoomCurve && !isZoomConstant) {
return error([
new ParsingError(
'',
'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'
)
]);
}
if (zoomCurve instanceof ParsingError) {
return error([zoomCurve]);
}
if (zoomCurve instanceof Interpolate && !supportsInterpolation(propertySpec)) {
return error([new ParsingError('', '"interpolate" expressions cannot be used with this property')]);
}
if (!zoomCurve) {
return success(
isFeatureConstant
? new ZoomConstantExpression('constant', expression.value, globalState)
: new ZoomConstantExpression('source', expression.value, globalState)
);
}
return success(
isFeatureConstant
? new ZoomDependentExpression('camera', expression.value, zoomCurve, globalState)
: new ZoomDependentExpression('composite', expression.value, zoomCurve, globalState)
);
}
import { createFunction, isFunction } from '../function/index.js';
import { Color } from './values.js';
// serialization wrapper for old-style stop functions normalized to the
// expression interface
class StylePropertyFunction {
constructor(parameters, specification) {
this._parameters = parameters;
this._specification = specification;
Object.assign(this, createFunction(this._parameters, this._specification));
}
static deserialize(serialized) {
return new StylePropertyFunction(serialized._parameters, serialized._specification);
}
static serialize(input) {
return {
_parameters: input._parameters,
_specification: input._specification
};
}
}
function normalizePropertyExpression(value, specification, globalState) {
if (isFunction(value)) {
return new StylePropertyFunction(value, specification);
}
if (isExpression(value)) {
const expression = createPropertyExpression(value, specification, globalState);
if (expression.result === 'error') {
// this should have been caught in validation
throw new Error(expression.value.map(err => `${err.key}: ${err.message}`).join(', '));
}
return expression.value;
}
let constant = value;
if (typeof value === 'string' && specification.type === 'color') {
constant = Color.parse(value);
}
return {
globalStateRefs: new Set(),
kind: 'constant',
evaluate: () => constant
};
}
// Zoom-dependent expressions may only use ["zoom"] as the input to a top-level "step" or "interpolate"
// expression (collectively referred to as a "curve"). The curve may be wrapped in one or more "let" or
// "coalesce" expressions.
function findZoomCurve(expression) {
let result = null;
if (expression instanceof Let) {
result = findZoomCurve(expression.result);
} else if (expression instanceof Coalesce) {
for (const arg of expression.args) {
result = findZoomCurve(arg);
if (result) {
break;
}
}
} else if (
(expression instanceof Step || expression instanceof Interpolate) &&
expression.input instanceof CompoundExpression &&
expression.input.name === 'zoom'
) {
result = expression;
}
if (result instanceof ParsingError) {
return result;
}
expression.eachChild(child => {
const childResult = findZoomCurve(child);
if (childResult instanceof ParsingError) {
result = childResult;
} else if (!result && childResult) {
result = new ParsingError(
'',
'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'
);
} else if (result && childResult && result !== childResult) {
result = new ParsingError(
'',
'Only one zoom-based "step" or "interpolate" subexpression may be used in an expression.'
);
}
});
return result;
}
export function findGlobalStateRefs(expression, results = new Set()) {
if (expression instanceof GlobalState) {
results.add(expression.key);
}
expression.eachChild(childExpression => {
findGlobalStateRefs(childExpression, results);
});
return results;
}
import { array, BooleanType, ColorType, FormattedType, NumberType, StringType, ValueType } from './types.js';
function getExpectedType(spec) {
const types = {
color: ColorType,
string: StringType,
number: NumberType,
enum: StringType,
boolean: BooleanType,
formatted: FormattedType
};
if (spec.type === 'array') {
return array(types[spec.value] || ValueType, spec.length);
}
return types[spec.type];
}
function getDefaultValue(spec) {
if (spec.type === 'color' && isFunction(spec.default)) {
// Special case for heatmap-color: it uses the 'default:' to define a
// default color ramp, but createExpression expects a simple value to fall
// back to in case of runtime errors
return new Color(0, 0, 0, 0);
}
if (spec.type === 'color') {
return Color.parse(spec.default) || null;
}
if (spec.default === undefined) {
return null;
}
return spec.default;
}
export {
StyleExpression,
isExpression,
createExpression,
ZoomConstantExpression,
ZoomDependentExpression,
createPropertyExpression,
StylePropertyFunction,
normalizePropertyExpression,
Color,
colorSpaces,
CompoundExpression,
definitions,
interpolate
};