diff --git a/README.md b/README.md index 08674df..0367c38 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This is a plugin for [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js This plugin requires [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js/) v3.0.0 and above. -This plugin only supports the deprecated [legacy filter syntax](https://maplibre.org/maplibre-style-spec/deprecations/#other-filter) in the MapLibre Style Specification. It does not yet support expression-based filters, but a style layer may set a paint or layout property to an expression without any problem. +This plugin is able to manipulate both the deprecated [legacy filter syntax](https://maplibre.org/maplibre-style-spec/deprecations/#other-filter) and the newer [expression syntax](https://maplibre.org/maplibre-style-spec/expressions/) defined in the MapLibre Style Specification. The stylesheet must be backed by a vector tileset, such as [OpenHistoricalMap’s official vector tileset](https://wiki.openstreetmap.org/wiki/OpenHistoricalMap/Reuse#Vector_tiles_and_stylesheets), that includes the following properties in each tile layer: diff --git a/index.js b/index.js index 6f814bb..c6a712e 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,6 @@ +/// A prefix that uniquely identifies this plugin, prepended onto the name of any variable generated by this plugin. +const variablePrefix = 'maplibre_gl_dates'; + /** * Filters the map’s features by a date. * @@ -78,8 +81,27 @@ function dateFromUTC(year, month, day) { * that require the feature to coincide with the decimal year. */ function constrainFilterByDate(filter, decimalYear) { - if (filter && filter[0] === 'all' && - filter[1] && filter[1][0] === 'any') { + if (typeof filter === 'undefined') { + return; + } else if (isLegacyFilter(filter)) { + return constrainLegacyFilterByDate(filter, decimalYear); + } else { + return constrainExpressionFilterByDate(filter, decimalYear); + } +} + +/** + * Returns a modified version of the given legacy filter that only evaluates to + * true if the feature coincides with the given decimal year. + * + * @param filter The original layer filter using the legacy syntax. + * @param decimalYear The decimal year to filter by. + * @returns A filter similar to the given filter, but with added conditions + * that require the feature to coincide with the decimal year. If the filter + * previously been passed into this function, it surgically updates the filter. + */ +function constrainLegacyFilterByDate(filter, decimalYear) { + if (filter[0] === 'all' && filter[1] && filter[1][0] === 'any') { if (filter[1][2] && filter[1][2][0] === '<=' && filter[1][2][1] === 'start_decdate') { filter[1][2][2] = decimalYear; } @@ -89,15 +111,111 @@ function constrainFilterByDate(filter, decimalYear) { return filter; } - let dateFilter = [ + return [ + 'all', + [ + 'any', + ['!has', 'start_decdate'], + ['<=', 'start_decdate', decimalYear] + ], + [ + 'any', + ['!has', 'end_decdate'], + ['>=', 'end_decdate', decimalYear] + ], + filter, + ]; +} + +/** + * Returns a modified version of the given expression-based filter that only + * evaluates to true if the feature coincides with the given decimal year. + * + * @param filter The original layer filter using the expression syntax. + * @param decimalYear The decimal year to filter by. + * @returns A filter similar to the given filter, but with added conditions + * that require the feature to coincide with the decimal year. If the filter + * previously been passed into this function, it merely updates a variable. + */ +function constrainExpressionFilterByDate(filter, decimalYear) { + const decimalYearVariable = `${variablePrefix}__decimalYear`; + if (filter[0] === 'let' && filter[1] === decimalYearVariable) { + filter[2] = decimalYear; + return filter; + } + + let allExpression = [ 'all', - ['any', ['!has', 'start_decdate'], ['<=', 'start_decdate', decimalYear]], - ['any', ['!has', 'end_decdate'], ['>=', 'end_decdate', decimalYear]], + [ + 'any', + ['!', ['has', 'start_decdate']], + ['<=', ['get', 'start_decdate'], ['var', decimalYearVariable]] + ], + [ + 'any', + ['!', ['has', 'end_decdate']], + ['>=', ['get', 'end_decdate'], ['var', decimalYearVariable]] + ], + filter, ]; - if (filter) { - dateFilter.push(filter); + + return [ + 'let', + decimalYearVariable, decimalYear, + allExpression, + ]; +} + +/** + * Returns a Boolean indicating whether the given filter is definitely based on [the deprecated legacy filter syntax](https://maplibre.org/maplibre-style-spec/deprecations/#other-filter) and thus incompatible with an expression. + * + * @param filter A filter that is either based on the legacy syntax or an expression. + * @returns True if the filter is definitely based on the legacy syntax; false if it might be an expression. + */ +function isLegacyFilter(filter) { + if (!Array.isArray(filter) || filter.length < 2) { + return false; + } + + let args = filter.slice(1); + switch (filter[0]) { + case '!has': + case '!in': + case 'none': + // These are filters but not expression operators. + return true; + + case 'has': + // These are unlikely feature properties but are built-in legacy keys. + return args[0] === '$id' || args[0] === '$type'; + + case 'in': + return (// The legacy syntax includes all the possible matches inline. + args.length > 2 || + // These are unlikely feature properties but are built-in legacy keys. + args[0] === '$id' || args[0] === '$type' || + // The `in` expression only allows searching within a string or array. + typeof args[1] === 'number' || typeof args[1] === 'boolean' || + // It would be pointless to search for a string literal inside another string literal. + (typeof args[0] === 'string' && typeof args[1] === 'string')); + + case '==': + case '!=': + case '>': + case '>=': + case '<': + case '<=': + // An expression would require the string literal to be compared to another string literal, but it would be pointless to do so. + return typeof args[0] === 'string' && !Array.isArray(args[1]); + + case 'all': + case 'any': + // If any of the arguments is definitely a legacy filter, the whole thing is too. + return args.some(isLegacyFilter); + + default: + return false; } - return dateFilter; } if (typeof window !== 'undefined' && 'maplibregl' in window) { @@ -110,5 +228,8 @@ if (typeof window !== 'undefined' && 'maplibregl' in window) { decimalYearFromDate: decimalYearFromDate, dateFromISODate: dateFromISODate, constrainFilterByDate: constrainFilterByDate, + constrainLegacyFilterByDate: constrainLegacyFilterByDate, + constrainExpressionFilterByDate: constrainExpressionFilterByDate, + isLegacyFilter: isLegacyFilter, }; } diff --git a/index.spec.mjs b/index.spec.mjs index 77a4946..77c0172 100644 --- a/index.spec.mjs +++ b/index.spec.mjs @@ -5,7 +5,9 @@ import { featureFilter } from '@maplibre/maplibre-gl-style-spec'; import { dateFromISODate, decimalYearFromDate, - constrainFilterByDate, + constrainLegacyFilterByDate, + constrainExpressionFilterByDate, + isLegacyFilter, } from './index.js'; describe('dateFromISODate', () => { @@ -39,10 +41,63 @@ describe('decimalYearFromDate', () => { }); }); -describe('constrainFilterByDate', () => { +describe('isLegacyFilter', () => { + it('should reject expression primitives', () => { + assert.ok(!isLegacyFilter(true)); + assert.ok(!isLegacyFilter(false)); + assert.ok(!isLegacyFilter(3.1415)); + assert.ok(!isLegacyFilter(['pi'])); + }); + + it('should accept legacy-only operators', () => { + assert.ok(isLegacyFilter(['!has', 'end_date'])); + assert.ok(isLegacyFilter(['!in', 'class', 'primary', 'secondary', 'tertiary'])); + assert.ok(isLegacyFilter(['none', ['has', 'start_date'], ['has', 'end_date']])); + }); + + it('should reject expression-only operators', () => { + assert.ok(!isLegacyFilter(['coalesce', false, true])); + const variable = 'maplibre_gl_dates__decimalYear'; + assert.ok(!isLegacyFilter(['let', variable, 2013, ['var', variable]])); + }); + + it('should accept special keys', () => { + assert.ok(isLegacyFilter(['has', '$id'])); + assert.ok(isLegacyFilter(['has', '$type'])); + assert.ok(isLegacyFilter(['in', '$id', 0, 1, 2, 3])); + assert.ok(isLegacyFilter(['in', '$type', 'Point', 'LineString'])); + }); + + it('should reject non-key first arguments', () => { + assert.ok(!isLegacyFilter(['==', ['get', 'name'], 'North'])); + assert.ok(!isLegacyFilter(['in', ['get', 'name'], 'North South East West'])); + }); + + it('should accept inlined `in` values', () => { + assert.ok(isLegacyFilter(['in', 'class', 'primary', 'secondary', 'tertiary'])); + assert.ok(isLegacyFilter(['in', 'class', 'primary'])); + assert.ok(!isLegacyFilter(['in', 'class', ['primary']])); + }); + + it('should accept string-string comparisons', () => { + assert.ok(isLegacyFilter(['==', 'name', 'North'])); + assert.ok(isLegacyFilter(['in', 'name', 'North'])); + }); + + it('should accept legacy combining filters', () => { + assert.ok(!isLegacyFilter(['any', true, false])); + assert.ok(!isLegacyFilter(['all', true, false])); + assert.ok(!isLegacyFilter(['any', ['has', 'start_date'], ['has', 'end_date']])); + assert.ok(!isLegacyFilter(['all', ['has', 'start_date'], ['has', 'end_date']])); + assert.ok(isLegacyFilter(['any', ['==', '$type', 'Polygon'], ['has', 'end_date']])); + assert.ok(isLegacyFilter(['all', ['==', '$type', 'Polygon'], ['has', 'end_date']])); + }); +}); + +describe('constrainLegacyFilterByDate', () => { it('should upgrade top-level non-combining filter', () => { let original = ['in', 'class', 'primary', 'secondary', 'tertiary']; - let upgraded = constrainFilterByDate(original, 2013); + let upgraded = constrainLegacyFilterByDate(original, 2013); assert.equal(upgraded.length, 4); assert.equal(upgraded[0], 'all'); assert.deepEqual(upgraded[3], original); @@ -51,8 +106,8 @@ describe('constrainFilterByDate', () => { it('should update already upgraded filter', () => { let original = ['in', 'class', 'primary', 'secondary', 'tertiary']; - let upgraded = constrainFilterByDate(original, 2013); - let updated = constrainFilterByDate(upgraded, 2014); + let upgraded = constrainLegacyFilterByDate(original, 2013); + let updated = constrainLegacyFilterByDate(upgraded, 2014); assert.equal(upgraded.length, updated.length); assert.doesNotMatch(JSON.stringify(updated), /2013/); assert.match(JSON.stringify(updated), /2014/); @@ -60,7 +115,58 @@ describe('constrainFilterByDate', () => { it('should include features matching the selected date', () => { let decimalYear = 2013.5; - let upgraded = constrainFilterByDate(['has', 'building'], decimalYear); + let upgraded = constrainLegacyFilterByDate(['has', 'building'], decimalYear); + + let includesFeature = (start, end) => { + let properties = { building: 'yes' }; + if (typeof start !== 'undefined') { + properties.start_decdate = start; + } + if (typeof end !== 'undefined') { + properties.end_decdate = end; + } + return featureFilter(upgraded).filter(undefined, { properties: properties }); + }; + + let dayDelta = 1/365; + assert.ok(includesFeature(undefined, undefined)); + assert.ok(!includesFeature(undefined, decimalYear - dayDelta)) + assert.ok(includesFeature(decimalYear - dayDelta, undefined)) + assert.ok(includesFeature(undefined, decimalYear + dayDelta)) + assert.ok(!includesFeature(decimalYear + dayDelta, undefined)) + }); +}); + +describe('constrainExpressionFilterByDate', () => { + it('should upgrade non-variable-binding filter', () => { + let original = ['match', ['get', 'class'], ['primary', 'secondary', 'tertiary'], true, false]; + let upgraded = constrainExpressionFilterByDate(original, 2013); + assert.equal(upgraded.length, 4); + assert.equal(upgraded[0], 'let'); + let variable = 'maplibre_gl_dates__decimalYear'; + assert.equal(upgraded[1], variable); + assert.equal(upgraded[2], 2013); + assert.equal(upgraded[3].length, 4); + assert.equal(upgraded[3][0], 'all'); + assert.match(JSON.stringify(upgraded[3][1]), new RegExp(variable)); + assert.match(JSON.stringify(upgraded[3][2]), new RegExp(variable)); + assert.deepEqual(upgraded[3][3], original); + }); + + it('should update already upgraded filter', () => { + let original = ['match', ['get', 'class'], ['primary', 'secondary', 'tertiary'], true, false]; + let upgraded = constrainExpressionFilterByDate(original, 2013); + let updated = constrainExpressionFilterByDate(upgraded, 2014); + assert.equal(upgraded.length, updated.length); + assert.equal(updated[0], 'let'); + assert.equal(upgraded[1], upgraded[1]); + assert.equal(updated[2], 2014); + assert.deepEqual(upgraded[3], updated[3]); + }); + + it('should include features matching the selected date', () => { + let decimalYear = 2013.5; + let upgraded = constrainExpressionFilterByDate(['has', 'building'], decimalYear); let includesFeature = (start, end) => { let properties = { building: 'yes' };