@maplibre/maplibre-gl-style-spec
Version:
a specification for maplibre styles
348 lines (309 loc) • 11.2 kB
text/typescript
import {createExpression, findGlobalStateRefs} from '../expression';
import type {GlobalProperties, Feature} from '../expression';
import {ICanonicalTileID} from '../tiles_and_coordinates';
import {StylePropertySpecification} from '..';
import {ExpressionFilterSpecification, type FilterSpecification} from '../types.g';
type FilterExpression = (
globalProperties: GlobalProperties,
feature: Feature,
canonical?: ICanonicalTileID
) => boolean;
export type FeatureFilter = {
filter: FilterExpression;
needGeometry: boolean;
getGlobalStateRefs: () => Set<string>;
};
type MixedFilterDiagnostic = {
path: Array<number>;
legacyFilter: Array<any>;
};
export function isExpressionFilter(filter: any): filter is ExpressionFilterSpecification {
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':
return false;
case '==':
case '!=':
case '>':
case '>=':
case '<':
case '<=':
return filter.length !== 3 || Array.isArray(filter[1]) || Array.isArray(filter[2]);
case 'none': {
for (const f of filter.slice(1)) {
if (typeof f === 'boolean') continue;
if (isExpressionFilter(f)) {
return true;
}
}
return false;
}
case 'any':
case 'all': {
let hasLegacy = false;
for (const f of filter.slice(1)) {
if (typeof f === 'boolean') continue;
if (isExpressionFilter(f)) {
// If any child is definitely an expression, treat the whole filter as an expression
// that will go through extensive validation to surface mixed syntax issues.
return true;
}
hasLegacy = true;
}
return !hasLegacy;
}
default:
return true;
}
}
function getFilterPropertyExpression(property: string): unknown {
if (property === '$type') return ['geometry-type'];
if (property === '$id') return ['id'];
return ['get', property];
}
function getLegacyFilterExpressionSuggestion(filter: Array<any>): unknown {
switch (filter[0]) {
case '==':
case '!=':
case '<':
case '<=':
case '>':
case '>=':
if (filter.length !== 3 || typeof filter[1] !== 'string') return null;
return [filter[0], getFilterPropertyExpression(filter[1]), filter[2]];
case 'in':
case '!in': {
if (filter.length < 2 || typeof filter[1] !== 'string') return null;
const expression = [
'in',
getFilterPropertyExpression(filter[1]),
['literal', filter.slice(2)]
];
return filter[0] === '!in' ? ['!', expression] : expression;
}
case 'has':
case '!has': {
if (filter.length !== 2 || typeof filter[1] !== 'string') return null;
if (filter[1] === '$type' || filter[1] === '$id') return null;
const expression = ['has', filter[1]];
return filter[0] === '!has' ? ['!', expression] : expression;
}
default:
return null;
}
}
export function getMixedFilterErrorMessage(filter: Array<any>): string {
if (
(filter[0] === '<' || filter[0] === '<=' || filter[0] === '>' || filter[0] === '>=') &&
filter[1] === '$type'
) {
return `"$type" cannot be use with operator "${filter[0]}"`;
}
const suggestion = getLegacyFilterExpressionSuggestion(filter);
if (suggestion) {
return `Mixing deprecated filter syntax with expression syntax is not supported. Replace ${JSON.stringify(filter)} with ${JSON.stringify(suggestion)}.`;
}
return `Mixing deprecated filter syntax with expression syntax is not supported. Convert ${JSON.stringify(filter)} to expression syntax.`;
}
function checkChild(
index: number,
path: Array<number>,
filter: unknown
): MixedFilterDiagnostic | null {
const child = filter[index];
if (!Array.isArray(child)) {
return null;
}
if (!isExpressionFilter(child)) {
return {path: path.concat(index), legacyFilter: child};
}
return findMixedLegacyFilter(child, path.concat(index));
}
export function findMixedLegacyFilter(
filter: unknown,
path: Array<number> = []
): MixedFilterDiagnostic | null {
if (!Array.isArray(filter) || filter.length < 1) {
return null;
}
switch (filter[0]) {
case 'all':
case 'any':
case 'none':
for (let i = 1; i < filter.length; i++) {
const diagnostic = checkChild(i, path, filter);
if (diagnostic) return diagnostic;
}
break;
case '!': {
const diagnostic = checkChild(1, path, filter);
if (diagnostic) return diagnostic;
break;
}
case 'case':
for (let i = 1; i < filter.length - 1; i += 2) {
const diagnostic = checkChild(i, path, filter);
if (diagnostic) return diagnostic;
}
break;
}
return null;
}
export function validateNoMixedExpressionFilter(filter: FilterSpecification) {
const diagnostic = findMixedLegacyFilter(filter);
if (diagnostic) {
throw new Error(getMixedFilterErrorMessage(diagnostic.legacyFilter));
}
}
const filterSpec = {
type: 'boolean',
default: false,
transition: false,
'property-type': 'data-driven',
expression: {
interpolated: false,
parameters: ['zoom', 'feature']
}
};
/**
* 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 filter MapLibre filter
* @param [globalState] Global state object to be used for evaluating 'global-state' expressions
* @returns filter-evaluating function
*/
export function featureFilter(
filter: FilterSpecification | void,
globalState?: Record<string, any>
): FeatureFilter {
if (filter === null || filter === undefined) {
return {filter: () => true, needGeometry: false, getGlobalStateRefs: () => new Set()};
}
if (Array.isArray(filter) && filter[0] === 'none' && isExpressionFilter(filter)) {
validateNoMixedExpressionFilter(filter);
filter = convertFilter(filter as Array<any>) as ExpressionFilterSpecification;
} else if (!isExpressionFilter(filter)) {
filter = convertFilter(filter) as ExpressionFilterSpecification;
} else {
validateNoMixedExpressionFilter(filter);
}
const compiled = createExpression(
filter,
filterSpec as StylePropertySpecification,
globalState
);
if (compiled.result === 'error') {
throw new Error(compiled.value.map((err) => `${err.key}: ${err.message}`).join(', '));
} else {
const needGeometry = geometryNeeded(filter);
return {
filter: (
globalProperties: GlobalProperties,
feature: Feature,
canonical?: ICanonicalTileID
) => compiled.value.evaluate(globalProperties, feature, {}, canonical),
needGeometry,
getGlobalStateRefs: () => findGlobalStateRefs(compiled.value.expression)
};
}
}
// Comparison function to sort numbers and strings
function compare(a, b) {
return a < b ? -1 : a > b ? 1 : 0;
}
function geometryNeeded(filter) {
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;
}
function convertFilter(filter?: Array<any> | null | void): 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))
: op === 'all'
? ['all' as unknown].concat(filter.slice(1).map(convertFilter))
: op === 'none'
? ['all' as unknown].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;
}
function convertComparisonOp(property: string, value: any, op: string) {
switch (property) {
case '$type':
return [`filter-type-${op}`, value];
case '$id':
return [`filter-id-${op}`, value];
default:
return [`filter-${op}`, property, value];
}
}
function convertDisjunctionOp(filters: Array<Array<any>>) {
return ['any' as unknown].concat(filters.map(convertFilter));
}
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];
}