diff --git a/README.md b/README.md index 0367c38..182a11c 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,16 @@ The stylesheet must be backed by a vector tileset, such as [OpenHistoricalMap’ Property | Type | Description ----|----|---- -`start_decdate` | Number | The date the feature came into existence in decimal year format. -`end_decdate` | Number | The date the feature went out of existence in decimal year format. +`start_date` | String | The date the feature came into existence as a date string. +`start_decdate` | Number | The date the feature came into existence as a decimal year. +`end_date` | String | The date the feature went out of existence as a date string. +`end_decdate` | Number | The date the feature went out of existence as a decimal year. -Decimal year format is defined as a floating-point number in the proleptic Gregorian calendar, such that each integer represents midnight on New Year’s Day. As there is no Year Zero, the value 1.0 falls on New Year’s Day of 1 CE, the value 0.0 falls on 1 BCE, the value -1.0 falls on 2 BCE, etc. An implementation of decimal year conversion functions is available [for PL/pgSQL ](https://github.com/OpenHistoricalMap/DateFunctions-plpgsql/). +A date string is a date in `YYYY`, `YYYY-MM`, or `YYYY-MM-DD` format, similar to ISO 8601-1 format. A decimal year is a floating-point number such that each integer represents midnight on New Year’s Day. An implementation of decimal year conversion functions is available [for PL/pgSQL](https://github.com/OpenHistoricalMap/DateFunctions-plpgsql/). + +All properties are optional, but the plugin will only have an effect if one or more of these properties is present in the tileset. For performance reasons, if a given feature has a `start_decdate` or `end_decdate` property, this plugin prefers it over the `start_date` or `end_date` property. + +Regardless of the data type, all dates are interpreted according to the proleptic Gregorian calendar. As there is no Year Zero, the value 1.0 falls on New Year’s Day of 1 CE, the value 0.0 falls on 1 BCE, the value -1.0 falls on 2 BCE, etc. ## Installation @@ -73,7 +79,7 @@ Parameter | Type | Description ----|----|---- `date` | `Date` or date string | The date to filter by. -A date string is defined as a date in `YYYY`, `YYYY-MM`, or `YYYY-MM-DD` format, similar to ISO 8601-1 format. Negative years are supported as described in “[Requirements](#requirements)”. +A date string is defined as a date in `YYYY`, `YYYY-MM`, or `YYYY-MM-DD` format, similar to ISO 8601-1 format. If the date is only given to year precision, every feature overlapping that year is included; likewise, if the date is given to month precision, every feature overlapping that month is included. Negative years are supported as described in “[Requirements](#requirements)”. ## Feedback diff --git a/index.js b/index.js index dcd1154..2384e11 100644 --- a/index.js +++ b/index.js @@ -27,13 +27,16 @@ function dateRangeFromDate(date) { let dateRange; if (typeof date === 'string') { dateRange = dateRangeFromISODate(date); - } else if (date instanceof Date) { - let decimalYear = !isNaN(date) && decimalYearFromDate(date); + } else if (date instanceof Date && !isNaN(date)) { + let decimalYear = decimalYearFromDate(date); + let isoDate = date.toISOString().split('T')[0]; dateRange = { startDate: date, startDecimalYear: decimalYear, + startISODate: isoDate, endDate: date, endDecimalYear: decimalYear, + endISODate: isoDate, }; } return dateRange; @@ -84,8 +87,10 @@ function dateRangeFromISODate(isoDate) { return { startDate: !isNaN(startDate) && startDate, startDecimalYear: !isNaN(startDate) && decimalYearFromDate(startDate), + startISODate: !isNaN(startDate) && startDate.toISOString().split('T')[0], endDate: !isNaN(endDate) && endDate, endDecimalYear: !isNaN(endDate) && decimalYearFromDate(endDate), + endISODate: !isNaN(endDate) && endDate.toISOString().split('T')[0], }; } @@ -152,12 +157,27 @@ function constrainFilterByDateRange(filter, dateRange) { * previously been passed into this function, it surgically updates the filter. */ function constrainLegacyFilterByDateRange(filter, dateRange) { - 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] = dateRange.endDecimalYear; + if (filter[0] === 'all' && + filter[2] && filter[1][0] === 'any' && filter[2][0] === 'any') { + if (filter[1][1] && filter[1][1][0] === 'all' && + filter[1][1][2] && filter[1][1][2][0] === '<' && + filter[1][1][2][1] === 'start_decdate') { + filter[1][1][2][2] = dateRange.endDecimalYear; } - if (filter[2][2] && filter[2][2][0] === '>=' && filter[2][2][1] === 'end_decdate') { - filter[2][2][2] = dateRange.startDecimalYear; + if (filter[1][2] && filter[1][2][0] === 'all' && + filter[1][2][2] && filter[1][2][2][0] === '<' && + filter[1][2][2][1] === 'start_date') { + filter[1][2][2][2] = dateRange.endISODate; + } + if (filter[2][1] && filter[2][1][0] === 'all' && + filter[2][1][2] && filter[2][1][2][0] === '>=' && + filter[2][1][2][1] === 'end_decdate') { + filter[2][1][2][2] = dateRange.startDecimalYear; + } + if (filter[2][2] && filter[2][2][0] === 'all' && + filter[2][2][2] && filter[2][2][2][0] === '>=' && + filter[2][2][2][1] === 'end_date') { + filter[2][2][2][2] = dateRange.startISODate; } return filter; } @@ -166,13 +186,39 @@ function constrainLegacyFilterByDateRange(filter, dateRange) { 'all', [ 'any', - ['!has', 'start_decdate'], - ['<', 'start_decdate', dateRange.endDecimalYear] + [ + 'all', + ['has', 'start_decdate'], + ['<', 'start_decdate', dateRange.endDecimalYear], + ], + [ + 'all', + ['has', 'start_date'], + ['<', 'start_date', dateRange.endISODate], + ], + [ + 'all', + ['!has', 'start_decdate'], + ['!has', 'start_date'], + ], ], [ 'any', - ['!has', 'end_decdate'], - ['>=', 'end_decdate', dateRange.startDecimalYear] + [ + 'all', + ['has', 'end_decdate'], + ['>=', 'end_decdate', dateRange.startDecimalYear], + ], + [ + 'all', + ['has', 'end_date'], + ['>=', 'end_date', dateRange.startISODate], + ], + [ + 'all', + ['!has', 'end_decdate'], + ['!has', 'end_date'], + ], ], filter, ]; @@ -191,22 +237,14 @@ function constrainLegacyFilterByDateRange(filter, dateRange) { */ function constrainExpressionFilterByDateRange(filter, dateRange) { const startDecimalYearVariable = `${variablePrefix}__startDecimalYear`; + const startISODateVariable = `${variablePrefix}__startISODate`; const endDecimalYearVariable = `${variablePrefix}__endDecimalYear`; + const endISODateVariable = `${variablePrefix}__endISODate`; if (filter[0] === 'let') { - let startVariableIndex = filter.indexOf(startDecimalYearVariable); - if (startVariableIndex !== -1 && startVariableIndex % 2 === 1) { - filter[startVariableIndex + 1] = dateRange.startDecimalYear; - } else { - filter.splice(-1, 0, startDecimalYearVariable, dateRange.startDecimalYear); - } - - let endVariableIndex = filter.indexOf(endDecimalYearVariable); - if (endVariableIndex !== -1 && endVariableIndex % 2 === 1) { - filter[endVariableIndex + 1] = dateRange.endDecimalYear; - } else { - filter.splice(-1, 0, endDecimalYearVariable, dateRange.endDecimalYear); - } - + updateVariable(filter, startDecimalYearVariable, dateRange.startDecimalYear); + updateVariable(filter, startISODateVariable, dateRange.startISODate); + updateVariable(filter, endDecimalYearVariable, dateRange.endDecimalYear); + updateVariable(filter, endISODateVariable, dateRange.endISODate); return filter; } @@ -214,13 +252,39 @@ function constrainExpressionFilterByDateRange(filter, dateRange) { 'all', [ 'any', - ['!', ['has', 'start_decdate']], - ['<', ['get', 'start_decdate'], ['var', endDecimalYearVariable]] + [ + 'all', + ['has', 'start_decdate'], + ['<', ['get', 'start_decdate'], ['var', endDecimalYearVariable]], + ], + [ + 'all', + ['has', 'start_date'], + ['<', ['get', 'start_date'], ['var', endISODateVariable]], + ], + [ + 'all', + ['!', ['has', 'start_decdate']], + ['!', ['has', 'start_date']] + ], ], [ 'any', - ['!', ['has', 'end_decdate']], - ['>=', ['get', 'end_decdate'], ['var', startDecimalYearVariable]] + [ + 'all', + ['has', 'end_decdate'], + ['>=', ['get', 'end_decdate'], ['var', startDecimalYearVariable]], + ], + [ + 'all', + ['has', 'end_decdate'], + ['>=', ['get', 'end_date'], ['var', startISODateVariable]], + ], + [ + 'all', + ['!', ['has', 'end_decdate']], + ['!', ['has', 'end_date']] + ], ], filter, ]; @@ -228,7 +292,9 @@ function constrainExpressionFilterByDateRange(filter, dateRange) { return [ 'let', startDecimalYearVariable, dateRange.startDecimalYear, + startISODateVariable, dateRange.startISODate, endDecimalYearVariable, dateRange.endDecimalYear, + endISODateVariable, dateRange.endISODate, allExpression, ]; } @@ -285,19 +351,41 @@ function isLegacyFilter(filter) { } } +/** + * Mutates a `let` expression to have a new value for the variable by the given + * name. + * + * @param letExpression A `let` expression. + * @param name The name of the variable to mutate. + * @param newValue The variable’s new value. + */ +function updateVariable(letExpression, name, newValue) { + if (letExpression[0] !== 'let') { + return; + } + + let variableIndex = letExpression.indexOf(name); + if (variableIndex !== -1 && variableIndex % 2 === 1) { + letExpression[variableIndex + 1] = newValue; + } else { + letExpression.splice(-1, 0, name, newValue); + } +} + if (typeof window !== 'undefined' && 'maplibregl' in window) { maplibregl.Map.prototype.filterByDate = function (date) { filterByDate(this, date); }; } else if (typeof module !== 'undefined') { module.exports = { - filterByDate: filterByDate, - dateRangeFromDate: dateRangeFromDate, - decimalYearFromDate: decimalYearFromDate, - dateRangeFromISODate: dateRangeFromISODate, - constrainFilterByDateRange: constrainFilterByDateRange, - constrainLegacyFilterByDateRange: constrainLegacyFilterByDateRange, - constrainExpressionFilterByDateRange: constrainExpressionFilterByDateRange, - isLegacyFilter: isLegacyFilter, + filterByDate, + dateRangeFromDate, + decimalYearFromDate, + dateRangeFromISODate, + constrainFilterByDateRange, + constrainLegacyFilterByDateRange, + constrainExpressionFilterByDateRange, + isLegacyFilter, + updateVariable, }; } diff --git a/index.spec.mjs b/index.spec.mjs index 03f107a..1541576 100644 --- a/index.spec.mjs +++ b/index.spec.mjs @@ -9,6 +9,7 @@ import { constrainLegacyFilterByDateRange, constrainExpressionFilterByDateRange, isLegacyFilter, + updateVariable, } from './index.js'; describe('dateRangeFromISODate', () => { @@ -25,8 +26,10 @@ describe('dateRangeFromISODate', () => { assert.deepEqual(dateRangeFromISODate('2013'), { startDate: new Date('2013-01-01'), startDecimalYear: 2013, + startISODate: '2013-01-01', endDate: new Date('2014-01-01'), endDecimalYear: 2014, + endISODate: '2014-01-01', }); let monthPrecision = dateRangeFromISODate('2013-04'); @@ -96,8 +99,10 @@ describe('dateRangeFromDate', () => { assert.deepEqual(dateRangeFromDate('2013'), { startDate: new Date('2013-01-01'), startDecimalYear: 2013, + startISODate: '2013-01-01', endDate: new Date('2014-01-01'), endDecimalYear: 2014, + endISODate: '2014-01-01', }); }); @@ -105,9 +110,11 @@ describe('dateRangeFromDate', () => { assert.deepEqual(dateRangeFromDate(new Date('2013')), { startDate: new Date('2013-01-01'), startDecimalYear: 2013, + startISODate: '2013-01-01', // Despite an imprecise input string, a Date object is always a point in time. endDate: new Date('2013-01-01'), endDecimalYear: 2013, + endISODate: '2013-01-01', }); }); }); @@ -189,7 +196,9 @@ describe('constrainLegacyFilterByDateRange', () => { let dayDelta = 1/365; let dateRange = { startDecimalYear, - endDecimalYear: startDecimalYear + dayDelta, + startISODate: '2013-07-02', + endDecimalYear: startDecimalYear + dayDelta, + endISODate: '2013-07-03', }; let upgraded = constrainLegacyFilterByDateRange(['has', 'building'], dateRange); @@ -205,11 +214,40 @@ describe('constrainLegacyFilterByDateRange', () => { }; assert.ok(includesFeature(undefined, undefined)); - assert.ok(!includesFeature(undefined, startDecimalYear - dayDelta)) - assert.ok(includesFeature(startDecimalYear - dayDelta, undefined)) + assert.ok(!includesFeature(undefined, startDecimalYear - dayDelta)); + assert.ok(includesFeature(startDecimalYear - dayDelta, undefined)); assert.ok(includesFeature(startDecimalYear + dayDelta/2, startDecimalYear + dayDelta/2)); - assert.ok(includesFeature(undefined, startDecimalYear + dayDelta)) - assert.ok(!includesFeature(startDecimalYear + dayDelta, undefined)) + assert.ok(includesFeature(undefined, startDecimalYear + dayDelta)); + assert.ok(!includesFeature(startDecimalYear + dayDelta, undefined)); + }); + + it('should fall back to date string properties if decimal year properties are missing', () => { + let startDecimalYear = 2013.28219; + let dayDelta = 1/365; + let dateRange = { + startDecimalYear: startDecimalYear, + startISODate: '2013-04-14', + endDecimalYear: startDecimalYear + dayDelta, + endISODate: '2013-04-15', + }; + let upgraded = constrainLegacyFilterByDateRange(['has', 'building'], dateRange); + + let includesFeature = (start, end) => { + let properties = { building: 'yes' }; + if (typeof start !== 'undefined') { + properties.start_date = start; + } + if (typeof end !== 'undefined') { + properties.end_date = end; + } + return featureFilter(upgraded).filter(undefined, { properties: properties }); + }; + + assert.ok(includesFeature(undefined, undefined)); + assert.ok(!includesFeature(undefined, '2013-04-13')); + assert.ok(includesFeature('2013-04-13', undefined)); + assert.ok(includesFeature(undefined, '2013-04-15')); + assert.ok(!includesFeature('2013-04-15', undefined)); }); }); @@ -218,46 +256,66 @@ describe('constrainExpressionFilterByDateRange', () => { let original = ['match', ['get', 'class'], ['primary', 'secondary', 'tertiary'], true, false]; let dateRange = { startDecimalYear: 2013, + startISODate: '2013-01-01', endDecimalYear: 2014, + endISODate: '2014-01-01', }; let upgraded = constrainExpressionFilterByDateRange(structuredClone(original), dateRange); - assert.equal(upgraded.length, 6); + assert.equal(upgraded.length, 10); assert.equal(upgraded[0], 'let'); - let startVariable = 'maplibre_gl_dates__startDecimalYear'; - let endVariable = 'maplibre_gl_dates__endDecimalYear'; - assert.equal(upgraded[1], startVariable); + let startDecimalYearVariable = 'maplibre_gl_dates__startDecimalYear'; + let startISODateVariable = 'maplibre_gl_dates__startISODate'; + let endDecimalYearVariable = 'maplibre_gl_dates__endDecimalYear'; + let endISODateVariable = 'maplibre_gl_dates__endISODate'; + assert.equal(upgraded[1], startDecimalYearVariable); assert.equal(upgraded[2], 2013); - assert.equal(upgraded[3], endVariable); - assert.equal(upgraded[4], 2014); - assert.equal(upgraded[5].length, 4); - assert.equal(upgraded[5][0], 'all'); - assert.match(JSON.stringify(upgraded[5][1]), new RegExp(endVariable)); - assert.match(JSON.stringify(upgraded[5][2]), new RegExp(startVariable)); - assert.deepEqual(upgraded[5][3], original); + assert.equal(upgraded[3], startISODateVariable); + assert.equal(upgraded[4], '2013-01-01'); + assert.equal(upgraded[5], endDecimalYearVariable); + assert.equal(upgraded[6], 2014); + assert.equal(upgraded[7], endISODateVariable); + assert.equal(upgraded[8], '2014-01-01'); + assert.equal(upgraded[9].length, 4); + assert.equal(upgraded[9][0], 'all'); + assert.match(JSON.stringify(upgraded[9][1]), new RegExp(endDecimalYearVariable)); + assert.match(JSON.stringify(upgraded[9][2]), new RegExp(startDecimalYearVariable)); + assert.deepEqual(upgraded[9][3], original); }); it('should update variable-binding filter', () => { let original = ['let', 'language', 'sux', ['get', ['+', 'name', ['var', 'language']]]]; let dateRange = { startDecimalYear: 2013, + startISODate: '2013-01-01', endDecimalYear: 2014, + endISODate: '2014-01-01', }; let updated = constrainExpressionFilterByDateRange(structuredClone(original), dateRange); - assert.equal(original.length + 4, updated.length); + assert.equal(original.length + 8, updated.length); assert.equal(updated[0], 'let'); assert.equal(original[1], updated[1]); assert.equal(original[2], updated[2]); assert.equal(updated[3], 'maplibre_gl_dates__startDecimalYear'); assert.equal(updated[4], 2013); - assert.equal(updated[5], 'maplibre_gl_dates__endDecimalYear'); - assert.equal(updated[6], 2014); - assert.deepEqual(original[3], updated[7]); + assert.equal(updated[5], 'maplibre_gl_dates__startISODate'); + assert.equal(updated[6], '2013-01-01'); + assert.equal(updated[7], 'maplibre_gl_dates__endDecimalYear'); + assert.equal(updated[8], 2014); + assert.equal(updated[9], 'maplibre_gl_dates__endISODate'); + assert.equal(updated[10], '2014-01-01'); + assert.deepEqual(original[3], updated[11]); }); it('should update already upgraded filter', () => { let original = ['match', ['get', 'class'], ['primary', 'secondary', 'tertiary'], true, false]; - let upgraded = constrainExpressionFilterByDateRange(structuredClone(original), { startDecimalYear: 2013 }); - let updated = constrainExpressionFilterByDateRange(structuredClone(upgraded), { startDecimalYear: 2014 }); + let upgraded = constrainExpressionFilterByDateRange(structuredClone(original), { + startDecimalYear: 2013, + startISODate: '2013-01-01', + }); + let updated = constrainExpressionFilterByDateRange(structuredClone(upgraded), { + startDecimalYear: 2014, + startISODate: '2014-01-01', + }); assert.equal(upgraded.length, updated.length); assert.equal(updated[0], 'let'); assert.equal(upgraded[1], updated[1]); @@ -270,7 +328,9 @@ describe('constrainExpressionFilterByDateRange', () => { let dayDelta = 1/365; let dateRange = { startDecimalYear, - endDecimalYear: startDecimalYear + dayDelta, + startISODate: '2013-07-02', + endDecimalYear: startDecimalYear + dayDelta, + endISODate: '2013-07-03', }; let upgraded = constrainExpressionFilterByDateRange(['has', 'building'], dateRange); @@ -286,10 +346,48 @@ describe('constrainExpressionFilterByDateRange', () => { }; assert.ok(includesFeature(undefined, undefined)); - assert.ok(!includesFeature(undefined, startDecimalYear - dayDelta)) - assert.ok(includesFeature(startDecimalYear - dayDelta, undefined)) + assert.ok(!includesFeature(undefined, startDecimalYear - dayDelta)); + assert.ok(includesFeature(startDecimalYear - dayDelta, undefined)); assert.ok(includesFeature(startDecimalYear + dayDelta/2, startDecimalYear + dayDelta/2)); - assert.ok(includesFeature(undefined, startDecimalYear + dayDelta)) - assert.ok(!includesFeature(startDecimalYear + dayDelta, undefined)) + assert.ok(includesFeature(undefined, startDecimalYear + dayDelta)); + assert.ok(!includesFeature(startDecimalYear + dayDelta, undefined)); + }); + + it('should fall back to date string properties if decimal year properties are missing', () => { + let startDecimalYear = 2013.28219; + let dayDelta = 1/365; + let dateRange = { + startDecimalYear: startDecimalYear, + startISODate: '2013-04-14', + endDecimalYear: startDecimalYear + dayDelta, + endISODate: '2013-04-15', + }; + let upgraded = constrainExpressionFilterByDateRange(['has', 'building'], dateRange); + + let includesFeature = (start, end) => { + let properties = { building: 'yes' }; + if (typeof start !== 'undefined') { + properties.start_date = start; + } + if (typeof end !== 'undefined') { + properties.end_date = end; + } + return featureFilter(upgraded).filter(undefined, { properties: properties }); + }; + + assert.ok(includesFeature(undefined, undefined)); + assert.ok(!includesFeature(undefined, '2013-04-13')); + assert.ok(includesFeature('2013-04-13', undefined)); + assert.ok(!includesFeature(undefined, '2013-04-15'), 'End date should be exclusive.'); + assert.ok(!includesFeature('2013-04-15', undefined)); + }); +}); + +describe('updateVariable', () => { + it('should ignore non-let expressions', () => { + let original = ['has', 'building']; + let updated = structuredClone(original); + updateVariable(updated, 'startISODate', '2013-04-14'); + assert.deepEqual(original, updated); }); });