UNPKG

@maplibre/maplibre-gl-style-spec

Version:
348 lines (309 loc) 11.2 kB
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]; }