@maplibre/maplibre-gl-style-spec
Version:
a specification for maplibre styles
750 lines (689 loc) • 24.5 kB
text/typescript
import {extendBy} from '../util/extend';
import {ExpressionParsingError} from './parsing_error';
import {ParsingContext} from './parsing_context';
import {EvaluationContext} from './evaluation_context';
import {
CompoundExpression,
isFeatureConstant,
isGlobalPropertyConstant,
isStateConstant,
isExpressionConstant
} from './compound_expression';
import {Step} from './definitions/step';
import {Interpolate} from './definitions/interpolate';
import {Coalesce} from './definitions/coalesce';
import {Let} from './definitions/let';
import {expressions} from './definitions';
import {RuntimeError} from './runtime_error';
import {success, error} from '../util/result';
import {
supportsPropertyExpression,
supportsZoomExpression,
supportsInterpolation
} from '../util/properties';
import {
ColorType,
StringType,
NumberType,
BooleanType,
ValueType,
FormattedType,
PaddingType,
ResolvedImageType,
VariableAnchorOffsetCollectionType,
array,
type Type,
type EvaluationKind,
ProjectionDefinitionType,
NumberArrayType,
ColorArrayType
} from './types';
import type {Value} from './values';
import type {Expression} from './expression';
import {type StylePropertySpecification} from '..';
import type {Result} from '../util/result';
import type {InterpolationType} from './definitions/interpolate';
import type {
PaddingSpecification,
NumberArraySpecification,
ColorArraySpecification,
PropertyValueSpecification,
VariableAnchorOffsetCollectionSpecification
} from '../types.g';
import type {FormattedSection} from './types/formatted';
import type {Point2D} from '../point2d';
import {ICanonicalTileID} from '../tiles_and_coordinates';
import {isFunction, createFunction} from '../function';
import {Color} from './types/color';
import {Padding} from './types/padding';
import {NumberArray} from './types/number_array';
import {ColorArray} from './types/color_array';
import {VariableAnchorOffsetCollection} from './types/variable_anchor_offset_collection';
import {ProjectionDefinition} from './types/projection_definition';
import {GlobalState} from './definitions/global_state';
export type Feature = {
readonly type:
| 0
| 1
| 2
| 3
| 'Unknown'
| 'Point'
| 'MultiPoint'
| 'LineString'
| 'MultiLineString'
| 'Polygon'
| 'MultiPolygon';
readonly id?: any;
readonly properties: {[_: string]: any};
readonly patterns?: {
[_: string]: {
min: string;
mid: string;
max: string;
};
};
readonly dashes?: {
[_: string]: {
min: string;
mid: string;
max: string;
};
};
readonly geometry?: Array<Array<Point2D>>;
};
export type FeatureState = {[_: string]: any};
export type GlobalProperties = Readonly<{
zoom: number;
heatmapDensity?: number;
elevation?: number;
lineProgress?: number;
isSupportedScript?: (_: string) => boolean;
accumulated?: Value;
globalState?: Record<string, any>;
}>;
export class StyleExpression {
expression: Expression;
_evaluator: EvaluationContext;
_defaultValue: Value;
_warningHistory: {[key: string]: boolean};
_enumValues: {[_: string]: any};
readonly _globalState: Record<string, any>;
constructor(
expression: Expression,
propertySpec?: StylePropertySpecification | null,
globalState?: Record<string, any>
) {
this.expression = expression;
this._warningHistory = {};
this._evaluator = new EvaluationContext();
this._defaultValue = propertySpec ? getDefaultValue(propertySpec) : null;
this._enumValues =
propertySpec && propertySpec.type === 'enum' ? propertySpec.values : null;
this._globalState = globalState;
}
evaluateWithoutErrorHandling(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
this._evaluator.globals = globals;
this._evaluator.feature = feature;
this._evaluator.featureState = featureState;
this._evaluator.canonical = canonical;
this._evaluator.availableImages = availableImages || null;
this._evaluator.formattedSection = formattedSection;
return this.expression.evaluate(this._evaluator);
}
evaluate(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
this._evaluator.globals = globals;
this._evaluator.feature = feature || null;
this._evaluator.featureState = featureState || null;
this._evaluator.canonical = canonical;
this._evaluator.availableImages = availableImages || null;
this._evaluator.formattedSection = formattedSection || null;
try {
const val = this.expression.evaluate(this._evaluator);
if (val === null || val === undefined || (typeof val === 'number' && val !== val)) {
return this._defaultValue;
}
if (this._enumValues && !(val in this._enumValues)) {
throw new RuntimeError(
`Expected value to be one of ${Object.keys(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;
}
}
}
export function isExpression(expression: unknown) {
return (
Array.isArray(expression) &&
expression.length > 0 &&
typeof expression[0] === 'string' &&
expression[0] in expressions
);
}
/**
* 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
*/
export function createExpression(
expression: unknown,
propertySpec?: StylePropertySpecification | null,
globalState?: Record<string, any>
): Result<StyleExpression, Array<ExpressionParsingError>> {
const parser = new ParsingContext(
expressions,
isExpressionConstant,
[],
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 && propertySpec.type === 'string' ? {typeAnnotation: 'coerce'} : undefined
);
if (!parsed) {
return error(parser.errors);
}
return success(new StyleExpression(parsed, propertySpec, globalState));
}
export class ZoomConstantExpression<Kind extends EvaluationKind> {
kind: Kind;
isStateDependent: boolean;
globalStateRefs: Set<string>;
_styleExpression: StyleExpression;
readonly _globalState: Record<string, any>;
constructor(kind: Kind, expression: StyleExpression, globalState?: Record<string, any>) {
this.kind = kind;
this._styleExpression = expression;
this.isStateDependent =
kind !== ('constant' as EvaluationKind) && !isStateConstant(expression.expression);
this.globalStateRefs = findGlobalStateRefs(expression.expression);
this._globalState = globalState;
}
evaluateWithoutErrorHandling(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
return this._styleExpression.evaluateWithoutErrorHandling(
globals,
feature,
featureState,
canonical,
availableImages,
formattedSection
);
}
evaluate(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
return this._styleExpression.evaluate(
globals,
feature,
featureState,
canonical,
availableImages,
formattedSection
);
}
}
export class ZoomDependentExpression<Kind extends EvaluationKind> {
kind: Kind;
zoomStops: Array<number>;
isStateDependent: boolean;
globalStateRefs: Set<string>;
_styleExpression: StyleExpression;
interpolationType: InterpolationType;
readonly _globalState: Record<string, any>;
constructor(
kind: Kind,
expression: StyleExpression,
zoomStops: Array<number>,
interpolationType?: InterpolationType,
globalState?: Record<string, any>
) {
this.kind = kind;
this.zoomStops = zoomStops;
this._styleExpression = expression;
this.isStateDependent =
kind !== ('camera' as EvaluationKind) && !isStateConstant(expression.expression);
this.globalStateRefs = findGlobalStateRefs(expression.expression);
this.interpolationType = interpolationType;
this._globalState = globalState;
}
evaluateWithoutErrorHandling(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
return this._styleExpression.evaluateWithoutErrorHandling(
globals,
feature,
featureState,
canonical,
availableImages,
formattedSection
);
}
evaluate(
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
): any {
if (this._globalState) {
globals = addGlobalState(globals, this._globalState);
}
return this._styleExpression.evaluate(
globals,
feature,
featureState,
canonical,
availableImages,
formattedSection
);
}
interpolationFactor(input: number, lower: number, upper: number): number {
if (this.interpolationType) {
return Interpolate.interpolationFactor(this.interpolationType, input, lower, upper);
} else {
return 0;
}
}
}
export function isZoomExpression(
expression: any
): expression is ZoomConstantExpression<'source'> | ZoomDependentExpression<'source'> {
return (expression as ZoomConstantExpression<'source'>)._styleExpression !== undefined;
}
export type ConstantExpression = {
kind: 'constant';
globalStateRefs: Set<string>;
readonly _globalState: Record<string, any>;
readonly evaluate: (
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>
) => any;
};
export type SourceExpression = {
kind: 'source';
isStateDependent: boolean;
globalStateRefs: Set<string>;
readonly _globalState: Record<string, any>;
readonly evaluate: (
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
) => any;
};
export type CameraExpression = {
kind: 'camera';
globalStateRefs: Set<string>;
readonly _globalState: Record<string, any>;
readonly evaluate: (
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>
) => any;
readonly interpolationFactor: (input: number, lower: number, upper: number) => number;
zoomStops: Array<number>;
interpolationType: InterpolationType;
};
export type CompositeExpression = {
kind: 'composite';
isStateDependent: boolean;
globalStateRefs: Set<string>;
readonly _globalState: Record<string, any>;
readonly evaluate: (
globals: GlobalProperties,
feature?: Feature,
featureState?: FeatureState,
canonical?: ICanonicalTileID,
availableImages?: Array<string>,
formattedSection?: FormattedSection
) => any;
readonly interpolationFactor: (input: number, lower: number, upper: number) => number;
zoomStops: Array<number>;
interpolationType: InterpolationType;
};
export type StylePropertyExpression =
| ConstantExpression
| SourceExpression
| CameraExpression
| CompositeExpression;
export function createPropertyExpression(
expressionInput: unknown,
propertySpec: StylePropertySpecification,
globalState?: Record<string, any>
): Result<StylePropertyExpression, Array<ExpressionParsingError>> {
const expression = createExpression(expressionInput, propertySpec, globalState);
if (expression.result === 'error') {
return expression;
}
const parsed = expression.value.expression;
const isFeatureConstantResult = isFeatureConstant(parsed);
if (!isFeatureConstantResult && !supportsPropertyExpression(propertySpec)) {
return error([new ExpressionParsingError('', 'data expressions not supported')]);
}
const isZoomConstant = isGlobalPropertyConstant(parsed, ['zoom']);
if (!isZoomConstant && !supportsZoomExpression(propertySpec)) {
return error([new ExpressionParsingError('', 'zoom expressions not supported')]);
}
const zoomCurve = findZoomCurve(parsed);
if (!zoomCurve && !isZoomConstant) {
return error([
new ExpressionParsingError(
'',
'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'
)
]);
} else if (zoomCurve instanceof ExpressionParsingError) {
return error([zoomCurve]);
} else if (zoomCurve instanceof Interpolate && !supportsInterpolation(propertySpec)) {
return error([
new ExpressionParsingError(
'',
'"interpolate" expressions cannot be used with this property'
)
]);
}
if (!zoomCurve) {
return success(
isFeatureConstantResult
? (new ZoomConstantExpression(
'constant',
expression.value,
globalState
) as ConstantExpression)
: (new ZoomConstantExpression(
'source',
expression.value,
globalState
) as SourceExpression)
);
}
const interpolationType =
zoomCurve instanceof Interpolate ? zoomCurve.interpolation : undefined;
return success(
isFeatureConstantResult
? (new ZoomDependentExpression(
'camera',
expression.value,
zoomCurve.labels,
interpolationType,
globalState
) as CameraExpression)
: (new ZoomDependentExpression(
'composite',
expression.value,
zoomCurve.labels,
interpolationType,
globalState
) as CompositeExpression)
);
}
// serialization wrapper for old-style stop functions normalized to the
// expression interface
export class StylePropertyFunction<T> {
_parameters: PropertyValueSpecification<T>;
_specification: StylePropertySpecification;
kind: EvaluationKind;
evaluate: (globals: GlobalProperties, feature?: Feature) => any;
interpolationFactor: (input: number, lower: number, upper: number) => number;
zoomStops: Array<number>;
constructor(
parameters: PropertyValueSpecification<T>,
specification: StylePropertySpecification
) {
this._parameters = parameters;
this._specification = specification;
extendBy(this, createFunction(this._parameters, this._specification));
}
static deserialize<T>(serialized: {
_parameters: PropertyValueSpecification<T>;
_specification: StylePropertySpecification;
}) {
return new StylePropertyFunction(
serialized._parameters,
serialized._specification
) as StylePropertyFunction<T>;
}
static serialize<T>(input: StylePropertyFunction<T>) {
return {
_parameters: input._parameters,
_specification: input._specification
};
}
}
export function normalizePropertyExpression<T>(
value: PropertyValueSpecification<T>,
specification: StylePropertySpecification,
globalState?: Record<string, any>
): StylePropertyExpression {
if (isFunction(value)) {
return new StylePropertyFunction(value, specification) as any;
} else 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;
} else {
let constant: any = value;
if (specification.type === 'color' && typeof value === 'string') {
constant = Color.parse(value);
} else if (
specification.type === 'padding' &&
(typeof value === 'number' || Array.isArray(value))
) {
constant = Padding.parse(value as PaddingSpecification);
} else if (
specification.type === 'numberArray' &&
(typeof value === 'number' || Array.isArray(value))
) {
constant = NumberArray.parse(value as NumberArraySpecification);
} else if (
specification.type === 'colorArray' &&
(typeof value === 'string' || Array.isArray(value))
) {
constant = ColorArray.parse(value as ColorArraySpecification);
} else if (
specification.type === 'variableAnchorOffsetCollection' &&
Array.isArray(value)
) {
constant = VariableAnchorOffsetCollection.parse(
value as VariableAnchorOffsetCollectionSpecification
);
} else if (specification.type === 'projectionDefinition' && typeof value === 'string') {
constant = ProjectionDefinition.parse(value);
}
return {
globalStateRefs: new Set<string>(),
_globalState: null,
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: Expression): Step | Interpolate | ExpressionParsingError | null {
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 ExpressionParsingError) {
return result;
}
expression.eachChild((child) => {
const childResult = findZoomCurve(child);
if (childResult instanceof ExpressionParsingError) {
result = childResult;
} else if (!result && childResult) {
result = new ExpressionParsingError(
'',
'"zoom" expression may only be used as input to a top-level "step" or "interpolate" expression.'
);
} else if (result && childResult && result !== childResult) {
result = new ExpressionParsingError(
'',
'Only one zoom-based "step" or "interpolate" subexpression may be used in an expression.'
);
}
});
return result;
}
export function findGlobalStateRefs(
expression: Expression,
results = new Set<string>()
): Set<string> {
if (expression instanceof GlobalState) {
results.add(expression.key);
}
expression.eachChild((childExpression) => {
findGlobalStateRefs(childExpression, results);
});
return results;
}
function getExpectedType(spec: StylePropertySpecification): Type {
const types = {
color: ColorType,
string: StringType,
number: NumberType,
enum: StringType,
boolean: BooleanType,
formatted: FormattedType,
padding: PaddingType,
numberArray: NumberArrayType,
colorArray: ColorArrayType,
projectionDefinition: ProjectionDefinitionType,
resolvedImage: ResolvedImageType,
variableAnchorOffsetCollection: VariableAnchorOffsetCollectionType
};
if (spec.type === 'array') {
return array(types[spec.value] || ValueType, spec.length);
}
return types[spec.type];
}
function getDefaultValue(spec: StylePropertySpecification): Value {
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);
}
switch (spec.type) {
case 'color':
return Color.parse(spec.default) || null;
case 'padding':
return Padding.parse(spec.default) || null;
case 'numberArray':
return NumberArray.parse(spec.default) || null;
case 'colorArray':
return ColorArray.parse(spec.default) || null;
case 'variableAnchorOffsetCollection':
return VariableAnchorOffsetCollection.parse(spec.default) || null;
case 'projectionDefinition':
return ProjectionDefinition.parse(spec.default) || null;
default:
return spec.default === undefined ? null : spec.default;
}
}
function addGlobalState(
globals: GlobalProperties,
globalState: Record<string, any>
): GlobalProperties {
const {zoom, heatmapDensity, elevation, lineProgress, isSupportedScript, accumulated} =
globals ?? {};
return {
zoom,
heatmapDensity,
elevation,
lineProgress,
isSupportedScript,
accumulated,
globalState
};
}