@mapbox/mapbox-gl-style-spec
Version:
a specification for mapbox gl styles
378 lines (327 loc) • 12.8 kB
text/typescript
import latest from '../reference/latest';
import {deepUnbundle} from '../util/unbundle_jsonlint';
import {createExpression} from '../expression/index';
import {isFeatureConstant} from '../expression/is_constant';
import assert from 'assert';
import type Point from '@mapbox/point-geometry';
import type {CanonicalTileID} from '../types/tile_id';
import type {GlobalProperties, Feature} from '../expression/index';
import type {FilterSpecification, ExpressionSpecification} from '../types';
import type {ConfigOptions} from '../types/config_options';
export type FeatureDistanceData = {
bearing: [number, number];
center: [number, number];
scale: number;
};
export type FilterExpression = (
globalProperties: GlobalProperties,
feature: Feature,
canonical?: CanonicalTileID,
featureTileCoord?: Point,
featureDistanceData?: FeatureDistanceData,
) => boolean;
export type FeatureFilter = {
filter: FilterExpression;
dynamicFilter?: FilterExpression;
needGeometry: boolean;
needFeature: boolean;
};
export default createFilter;
export {isExpressionFilter, isDynamicFilter, extractStaticFilter};
function isExpressionFilter(filter: unknown): boolean {
if (filter === true || filter === false) {
return true;
}
if (!Array.isArray(filter) || filter.length === 0) {
return false;
}
switch (filter[0]) {
case 'has':
return filter.length >= 2 && filter[1] !== '$id' && filter[1] !== '$type';
case 'in':
return filter.length >= 3 && (typeof filter[1] !== 'string' || Array.isArray(filter[2]));
case '!in':
case '!has':
case 'none':
return false;
case '==':
case '!=':
case '>':
case '>=':
case '<':
case '<=':
return filter.length !== 3 || (Array.isArray(filter[1]) || Array.isArray(filter[2]));
case 'any':
case 'all':
for (const f of filter.slice(1)) {
if (!isExpressionFilter(f) && typeof f !== 'boolean') {
return false;
}
}
return true;
default:
return true;
}
}
/**
* Given a filter expressed as nested arrays, return a new function
* that evaluates whether a given feature (with a .properties or .tags property)
* passes its test.
*
* @private
* @param {Array} filter mapbox gl filter
* @param {string} layerType the type of the layer this filter will be applied to.
* @returns {Function} filter-evaluating function
*/
function createFilter(filter?: FilterSpecification, scope: string = "", options: ConfigOptions | null = null, layerType: string = 'fill'): FeatureFilter {
if (filter === null || filter === undefined) {
return {filter: () => true, needGeometry: false, needFeature: false};
}
if (!isExpressionFilter(filter)) {
filter = convertFilter(filter) as ExpressionSpecification;
}
const filterExp = (filter as string[] | string | boolean);
let staticFilter = true;
try {
staticFilter = extractStaticFilter(filterExp);
} catch (e) {
console.warn(
`Failed to extract static filter. Filter will continue working, but at higher memory usage and slower framerate.
This is most likely a bug, please report this via https://github.com/mapbox/mapbox-gl-js/issues/new?assignees=&labels=&template=Bug_report.md
and paste the contents of this message in the report.
Thank you!
Filter Expression:
${JSON.stringify(filterExp, null, 2)}
`);
}
// Compile the static component of the filter
let filterFunc = null;
let filterSpec = null;
if (layerType !== 'background' && layerType !== 'sky' && layerType !== 'slot') {
filterSpec = latest[`filter_${layerType}`];
assert(filterSpec);
const compiledStaticFilter = createExpression(staticFilter, filterSpec, scope, options);
if (compiledStaticFilter.result === 'error') {
throw new Error(compiledStaticFilter.value.map(err => `${err.key}: ${err.message}`).join(', '));
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
filterFunc = (globalProperties: GlobalProperties, feature: Feature, canonical?: CanonicalTileID) => compiledStaticFilter.value.evaluate(globalProperties, feature, {}, canonical);
}
}
// If the static component is not equal to the entire filter then we have a dynamic component
// Compile the dynamic component separately
let dynamicFilterFunc = null;
let needFeature = null;
if (staticFilter !== filterExp) {
const compiledDynamicFilter = createExpression(filterExp, filterSpec, scope, options);
if (compiledDynamicFilter.result === 'error') {
throw new Error(compiledDynamicFilter.value.map(err => `${err.key}: ${err.message}`).join(', '));
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
dynamicFilterFunc = (globalProperties: GlobalProperties, feature: Feature, canonical?: CanonicalTileID, featureTileCoord?: Point, featureDistanceData?: FeatureDistanceData) => compiledDynamicFilter.value.evaluate(globalProperties, feature, {}, canonical, undefined, undefined, featureTileCoord, featureDistanceData);
needFeature = !isFeatureConstant(compiledDynamicFilter.value.expression);
}
}
filterFunc = (filterFunc as FilterExpression);
const needGeometry = geometryNeeded(staticFilter);
return {
filter: filterFunc,
dynamicFilter: dynamicFilterFunc ? dynamicFilterFunc : undefined,
needGeometry,
needFeature: !!needFeature
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractStaticFilter(filter: any): any {
if (!isDynamicFilter(filter)) {
return filter;
}
// Shallow copy so we can replace expressions in-place
let result = deepUnbundle(filter);
// 1. Union branches
unionDynamicBranches(result);
// 2. Collapse dynamic conditions to `true`
result = collapseDynamicBooleanExpressions(result);
return result;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function collapseDynamicBooleanExpressions(expression: any): any {
if (!Array.isArray(expression)) {
return expression;
}
const collapsed = collapsedExpression(expression);
if (collapsed === true) {
return collapsed;
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return collapsed.map((subExpression) => collapseDynamicBooleanExpressions(subExpression));
}
}
/**
* Traverses the expression and replaces all instances of branching on a
* `dynamic` conditional (such as `['pitch']` or `['distance-from-center']`)
* into an `any` expression.
* This ensures that all possible outcomes of a `dynamic` branch are considered
* when evaluating the expression upfront during filtering.
*
* @param {Array<any>} filter the filter expression mutated in-place.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unionDynamicBranches(filter: any) {
let isBranchingDynamically = false;
const branches = [];
if (filter[0] === 'case') {
for (let i = 1; i < filter.length - 1; i += 2) {
isBranchingDynamically = isBranchingDynamically || isDynamicFilter(filter[i]);
branches.push(filter[i + 1]);
}
branches.push(filter[filter.length - 1]);
} else if (filter[0] === 'match') {
isBranchingDynamically = isBranchingDynamically || isDynamicFilter(filter[1]);
for (let i = 2; i < filter.length - 1; i += 2) {
branches.push(filter[i + 1]);
}
branches.push(filter[filter.length - 1]);
} else if (filter[0] === 'step') {
isBranchingDynamically = isBranchingDynamically || isDynamicFilter(filter[1]);
for (let i = 1; i < filter.length - 1; i += 2) {
branches.push(filter[i + 1]);
}
}
if (isBranchingDynamically) {
filter.length = 0;
filter.push('any', ...branches);
}
// traverse and recurse into children
for (let i = 1; i < filter.length; i++) {
unionDynamicBranches(filter[i]);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isDynamicFilter(filter: any): boolean {
// Base Cases
if (!Array.isArray(filter)) {
return false;
}
if (isRootExpressionDynamic(filter[0])) {
return true;
}
for (let i = 1; i < filter.length; i++) {
const child = filter[i];
if (isDynamicFilter(child)) {
return true;
}
}
return false;
}
function isRootExpressionDynamic(expression: string): boolean {
return expression === 'pitch' ||
expression === 'distance-from-center';
}
const dynamicConditionExpressions = new Set([
'in',
'==',
'!=',
'>',
'>=',
'<',
'<=',
'to-boolean'
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function collapsedExpression(expression: any): any {
if (dynamicConditionExpressions.has(expression[0])) {
for (let i = 1; i < expression.length; i++) {
const param = expression[i];
if (isDynamicFilter(param)) {
return true;
}
}
}
return expression;
}
// Comparison function to sort numbers and strings
function compare(a: number, b: number) {
return a < b ? -1 : a > b ? 1 : 0;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function geometryNeeded(filter: Array<any> | boolean) {
if (!Array.isArray(filter)) return false;
if (filter[0] === 'within' || filter[0] === 'distance') return true;
for (let index = 1; index < filter.length; index++) {
if (geometryNeeded(filter[index])) return true;
}
return false;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function convertFilter(filter?: Array<any> | null): unknown {
if (!filter) return true;
const op = filter[0];
if (filter.length <= 1) return (op !== 'any');
const converted =
op === '==' ? convertComparisonOp(filter[1], filter[2], '==') :
op === '!=' ? convertNegation(convertComparisonOp(filter[1], filter[2], '==')) :
op === '<' ||
op === '>' ||
op === '<=' ||
op === '>=' ? convertComparisonOp(filter[1], filter[2], op) :
op === 'any' ? convertDisjunctionOp(filter.slice(1)) :
// @ts-expect-error - TS2769 - No overload matches this call.
op === 'all' ? ['all'].concat(filter.slice(1).map(convertFilter)) :
// @ts-expect-error - TS2769 - No overload matches this call.
op === 'none' ? ['all'].concat(filter.slice(1).map(convertFilter).map(convertNegation)) :
op === 'in' ? convertInOp(filter[1], filter.slice(2)) :
op === '!in' ? convertNegation(convertInOp(filter[1], filter.slice(2))) :
op === 'has' ? convertHasOp(filter[1]) :
op === '!has' ? convertNegation(convertHasOp(filter[1])) :
true;
return converted;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function convertComparisonOp(property: string, value: any, op: string) {
switch (property) {
case '$type':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return [`filter-type-${op}`, value];
case '$id':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return [`filter-id-${op}`, value];
default:
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return [`filter-${op}`, property, value];
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function convertDisjunctionOp(filters: Array<Array<any>>) {
// @ts-expect-error - TS2769 - No overload matches this call.
return ['any'].concat(filters.map(convertFilter));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function convertInOp(property: string, values: Array<any>) {
if (values.length === 0) { return false; }
switch (property) {
case '$type':
return [`filter-type-in`, ['literal', values]];
case '$id':
return [`filter-id-in`, ['literal', values]];
default:
if (values.length > 200 && !values.some(v => typeof v !== typeof values[0])) {
return ['filter-in-large', property, ['literal', values.sort(compare)]];
} else {
return ['filter-in-small', property, ['literal', values]];
}
}
}
function convertHasOp(property: string) {
switch (property) {
case '$type':
return true;
case '$id':
return [`filter-has-id`];
default:
return [`filter-has`, property];
}
}
function convertNegation(filter: unknown) {
return ['!', filter];
}