Skip to content

Commit

Permalink
Added support for expression-based filters
Browse files Browse the repository at this point in the history
  • Loading branch information
1ec5 committed Aug 5, 2024
1 parent 6a01f2e commit c5b6c3f
Show file tree
Hide file tree
Showing 3 changed files with 242 additions and 15 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
137 changes: 129 additions & 8 deletions index.js
Original file line number Diff line number Diff line change
@@ -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.
*
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -110,5 +228,8 @@ if (typeof window !== 'undefined' && 'maplibregl' in window) {
decimalYearFromDate: decimalYearFromDate,
dateFromISODate: dateFromISODate,
constrainFilterByDate: constrainFilterByDate,
constrainLegacyFilterByDate: constrainLegacyFilterByDate,
constrainExpressionFilterByDate: constrainExpressionFilterByDate,
isLegacyFilter: isLegacyFilter,
};
}
118 changes: 112 additions & 6 deletions index.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { featureFilter } from '@maplibre/maplibre-gl-style-spec';
import {
dateFromISODate,
decimalYearFromDate,
constrainFilterByDate,
constrainLegacyFilterByDate,
constrainExpressionFilterByDate,
isLegacyFilter,
} from './index.js';

describe('dateFromISODate', () => {
Expand Down Expand Up @@ -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);
Expand All @@ -51,16 +106,67 @@ 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/);
});

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' };
Expand Down

0 comments on commit c5b6c3f

Please sign in to comment.