From a2e3d6f327fe798f8dff283561e661c957a701c2 Mon Sep 17 00:00:00 2001 From: flammermann Date: Wed, 27 Dec 2023 23:29:26 +0000 Subject: [PATCH 01/24] Support date properties in facet search --- umap/static/umap/js/umap.controls.js | 52 +++++++++++++++++++------ umap/static/umap/js/umap.core.js | 2 +- umap/static/umap/js/umap.features.js | 26 +++++++++++-- umap/static/umap/js/umap.forms.js | 57 ++++++++++++++++++++++++++-- umap/static/umap/js/umap.js | 7 +++- 5 files changed, 121 insertions(+), 23 deletions(-) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 6f9a853b..dc3adeff 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -663,22 +663,50 @@ const ControlsMixin = { _openFacet: function () { const container = L.DomUtil.create('div', 'umap-facet-search'), title = L.DomUtil.add('h3', 'umap-filter-title', container, L._('Facet search')), - keys = Object.keys(this.getFacetKeys()) + facetKeys = this.getFacetKeys(), + keys = Object.keys(facetKeys) - const knownValues = {} + const facetCriteria = {} keys.forEach((key) => { - knownValues[key] = [] - if (!this.facets[key]) this.facets[key] = [] + if (facetKeys[key]["type"] === "date") { + if (!facetCriteria[key]) facetCriteria[key] = { + "min": undefined, + "max": undefined + } + if (!this.facets[key]) this.facets[key] = { + "type": facetKeys[key]["type"], + "min": undefined, + "max": undefined + } + } else { + if (!facetCriteria[key]) facetCriteria[key] = { + "choices": [] + } + if (!this.facets[key]) this.facets[key] = { + "type": facetKeys[key]["type"], + "choices": [] + } + } }) this.eachBrowsableDataLayer((datalayer) => { datalayer.eachFeature((feature) => { keys.forEach((key) => { let value = feature.properties[key] - if (typeof value !== 'undefined' && !knownValues[key].includes(value)) { - knownValues[key].push(value) - } + if (facetKeys[key]["type"] === "date") { + value = feature.parseDateField(value) + if (!!value && (!facetCriteria[key]["min"] || facetCriteria[key]["min"] > value)) { + facetCriteria[key]["min"] = value + } + if (!!value && (!facetCriteria[key]["max"] || facetCriteria[key]["max"] < value)) { + facetCriteria[key]["max"] = value + } + } else { + if (!!value && !facetCriteria[key]["choices"].includes(value)) { + facetCriteria[key]["choices"].push(value) + } + } }) }) }) @@ -694,12 +722,12 @@ const ControlsMixin = { this.ui.alert({ content: L._('No results for these facets'), level: 'info' }) } - const fields = keys.map((current) => [ - `facets.${current}`, + const fields = keys.map((key) => [ + `facets.${key}`, { - handler: 'FacetSearch', - choices: knownValues[current], - label: this.getFacetKeys()[current], + handler: facetKeys[key]["type"] === "date" ? 'FacetSearchDate' : 'FacetSearchCheckbox', + criteria: facetCriteria[key], + label: facetKeys[key]["label"] }, ]) const builder = new U.FormBuilder(this, fields, { diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index c4c2c5e4..9d7ec504 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -541,7 +541,7 @@ U.Help = L.Class.extend({ slugKey: L._('The name of the property to use as feature unique identifier.'), filterKey: L._('Comma separated list of properties to use when filtering features'), facetKey: L._( - 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key)' + 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control type, add it after another | (eg.: mykey|My Key|checkbox,otherkey|Other Key|date)' ), interactive: L._( 'If false, the polygon or line will act as a part of the underlying map.' diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 60814720..cffa7f59 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -492,12 +492,30 @@ U.FeatureMixin = { return false }, + parseDateField: function (value) { + if (parseFloat(value).toString() === value.toString()) { + value = parseFloat(value); + if (value < 10000000000) { + value = value * 1000; + } + } + return new Date(value); + }, + matchFacets: function () { const facets = this.map.facets - for (const [property, expected] of Object.entries(facets)) { - if (expected.length) { - let value = this.properties[property] - if (!value || !expected.includes(value)) return false + for (const [property, criteria] of Object.entries(facets)) { + let value = this.properties[property] + const type = criteria["type"] + if (type === "date") { + const min = new Date(criteria["min"]) + const max = new Date(criteria["max"]) + value = this.parseDateField(value) + if (!!min && (!value || min > value)) return false + if (!!max && (!value || max < value)) return false + } else { + const choices = criteria["choices"] + if (choices.length && (!value || !choices.includes(value))) return false } } return true diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index f29460fd..575d5cab 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -744,11 +744,11 @@ L.FormBuilder.Switch = L.FormBuilder.CheckBox.extend({ }, }) -L.FormBuilder.FacetSearch = L.FormBuilder.Element.extend({ +L.FormBuilder.FacetSearchCheckbox = L.FormBuilder.Element.extend({ build: function () { this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode) this.ul = L.DomUtil.create('ul', '', this.container) - const choices = this.options.choices + const choices = this.options.criteria["choices"] choices.sort() choices.forEach((value) => this.buildLi(value)) }, @@ -763,7 +763,7 @@ L.FormBuilder.FacetSearch = L.FormBuilder.Element.extend({ label = L.DomUtil.create('label', '', property_li) input.type = 'checkbox' input.id = `checkbox_${this.name}_${value}` - input.checked = this.get().includes(value) + input.checked = this.get()['choices'].includes(value) input.dataset.value = value label.htmlFor = `checkbox_${this.name}_${value}` label.innerHTML = value @@ -771,10 +771,59 @@ L.FormBuilder.FacetSearch = L.FormBuilder.Element.extend({ }, toJS: function () { - return [...this.ul.querySelectorAll('input:checked')].map((i) => i.dataset.value) + return { + 'type': 'checkbox', + 'choices': [...this.ul.querySelectorAll('input:checked')].map((i) => i.dataset.value) + } }, }) +L.FormBuilder.FacetSearchDate = L.FormBuilder.Element.extend({ + build: function () { + this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode); + const min = this.options.criteria['min']; + const max = this.options.criteria['max']; + + // Create labels for min and max inputs + this.minLabel = L.DomUtil.create('label', '', this.container); + this.minLabel.innerHTML = 'Start'; + this.minLabel.htmlFor = `date_${this.name}_min`; + + this.minInput = L.DomUtil.create('input', '', this.container); + this.minInput.type = 'datetime-local'; + this.minInput.step = '0.001'; + this.minInput.id = `date_${this.name}_min`; + this.minInput.valueAsNumber = (min.valueOf() - min.getTimezoneOffset() * 60000);; + this.minInput.dataset.value = min; + + this.maxLabel = L.DomUtil.create('label', '', this.container); + this.maxLabel.innerHTML = 'End'; + this.maxLabel.htmlFor = `date_${this.name}_max`; + + this.maxInput = L.DomUtil.create('input', '', this.container); + this.maxInput.type = 'datetime-local'; + this.maxInput.step = '0.001'; + this.maxInput.id = `date_${this.name}_max`; + this.maxInput.valueAsNumber = (max.valueOf() - max.getTimezoneOffset() * 60000);; + this.maxInput.dataset.value = max; + + L.DomEvent.on(this.minInput, 'change', (e) => this.sync()); + L.DomEvent.on(this.maxInput, 'change', (e) => this.sync()); + }, + + buildLabel: function () { + this.label = L.DomUtil.add('h5', '', this.parentNode, this.options.label) + }, + + toJS: function () { + return { + 'type': 'date', + 'min': this.minInput.value, + 'max': this.maxInput.value, + }; + }, +}); + L.FormBuilder.MultiChoice = L.FormBuilder.Element.extend({ default: 'null', className: 'umap-multiplechoice', diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 2c247447..22a70c9d 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -1236,7 +1236,7 @@ U.Map = L.Map.extend({ { handler: 'Input', helpEntries: 'facetKey', - placeholder: L._('Example: key1,key2,key3'), + placeholder: L._('Example: key1,key2|Label 2,key3|Label 3|checkbox'), label: L._('Facet keys'), }, ], @@ -1848,7 +1848,10 @@ U.Map = L.Map.extend({ getFacetKeys: function () { return (this.options.facetKey || '').split(',').reduce((acc, curr) => { const els = curr.split('|') - acc[els[0]] = els[1] || els[0] + acc[els[0]] = { + "label": els[1] || els[0], + "type": els[2] || "checkbox" + } return acc }, {}) }, From 4de6bc6c915275104a4ad193caf5a16c708f9523 Mon Sep 17 00:00:00 2001 From: flammermann Date: Thu, 28 Dec 2023 00:26:09 +0000 Subject: [PATCH 02/24] Support date properties in facet search - fix indents --- umap/static/umap/js/umap.controls.js | 36 ++++++++++++++-------------- umap/static/umap/js/umap.features.js | 10 ++++---- umap/static/umap/js/umap.js | 4 ++-- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index dc3adeff..77e7d0f8 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -671,18 +671,18 @@ const ControlsMixin = { keys.forEach((key) => { if (facetKeys[key]["type"] === "date") { if (!facetCriteria[key]) facetCriteria[key] = { - "min": undefined, - "max": undefined - } + "min": undefined, + "max": undefined + } if (!this.facets[key]) this.facets[key] = { "type": facetKeys[key]["type"], - "min": undefined, - "max": undefined + "min": undefined, + "max": undefined } } else { if (!facetCriteria[key]) facetCriteria[key] = { - "choices": [] - } + "choices": [] + } if (!this.facets[key]) this.facets[key] = { "type": facetKeys[key]["type"], "choices": [] @@ -695,18 +695,18 @@ const ControlsMixin = { keys.forEach((key) => { let value = feature.properties[key] if (facetKeys[key]["type"] === "date") { - value = feature.parseDateField(value) - if (!!value && (!facetCriteria[key]["min"] || facetCriteria[key]["min"] > value)) { - facetCriteria[key]["min"] = value - } - if (!!value && (!facetCriteria[key]["max"] || facetCriteria[key]["max"] < value)) { - facetCriteria[key]["max"] = value - } - } else { - if (!!value && !facetCriteria[key]["choices"].includes(value)) { + value = feature.parseDateField(value) + if (!!value && (!facetCriteria[key]["min"] || facetCriteria[key]["min"] > value)) { + facetCriteria[key]["min"] = value + } + if (!!value && (!facetCriteria[key]["max"] || facetCriteria[key]["max"] < value)) { + facetCriteria[key]["max"] = value + } + } else { + if (!!value && !facetCriteria[key]["choices"].includes(value)) { facetCriteria[key]["choices"].push(value) } - } + } }) }) }) @@ -725,7 +725,7 @@ const ControlsMixin = { const fields = keys.map((key) => [ `facets.${key}`, { - handler: facetKeys[key]["type"] === "date" ? 'FacetSearchDate' : 'FacetSearchCheckbox', + handler: facetKeys[key]["type"] === "date" ? 'FacetSearchDate' : 'FacetSearchCheckbox', criteria: facetCriteria[key], label: facetKeys[key]["label"] }, diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index cffa7f59..297da47b 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -508,13 +508,13 @@ U.FeatureMixin = { let value = this.properties[property] const type = criteria["type"] if (type === "date") { - const min = new Date(criteria["min"]) - const max = new Date(criteria["max"]) - value = this.parseDateField(value) + const min = new Date(criteria["min"]) + const max = new Date(criteria["max"]) + value = this.parseDateField(value) if (!!min && (!value || min > value)) return false - if (!!max && (!value || max < value)) return false + if (!!max && (!value || max < value)) return false } else { - const choices = criteria["choices"] + const choices = criteria["choices"] if (choices.length && (!value || !choices.includes(value))) return false } } diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 22a70c9d..eba08365 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -1849,8 +1849,8 @@ U.Map = L.Map.extend({ return (this.options.facetKey || '').split(',').reduce((acc, curr) => { const els = curr.split('|') acc[els[0]] = { - "label": els[1] || els[0], - "type": els[2] || "checkbox" + "label": els[1] || els[0], + "type": els[2] || "checkbox" } return acc }, {}) From ba88dfafcdf7a21e892bd728cfd7f86e14745d87 Mon Sep 17 00:00:00 2001 From: flammermann Date: Tue, 2 Jan 2024 21:50:03 +0000 Subject: [PATCH 03/24] Support date properties in facet search - change default type --- umap/static/umap/js/umap.core.js | 2 +- umap/static/umap/js/umap.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 9d7ec504..4981086b 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -541,7 +541,7 @@ U.Help = L.Class.extend({ slugKey: L._('The name of the property to use as feature unique identifier.'), filterKey: L._('Comma separated list of properties to use when filtering features'), facetKey: L._( - 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control type, add it after another | (eg.: mykey|My Key|checkbox,otherkey|Other Key|date)' + 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control data type, add it after another | (eg.: mykey|My Key|enum,otherkey|Other Key|date)' ), interactive: L._( 'If false, the polygon or line will act as a part of the underlying map.' diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index eba08365..bdd2959d 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -1236,7 +1236,7 @@ U.Map = L.Map.extend({ { handler: 'Input', helpEntries: 'facetKey', - placeholder: L._('Example: key1,key2|Label 2,key3|Label 3|checkbox'), + placeholder: L._('Example: key1,key2|Label 2,key3|Label 3|enum'), label: L._('Facet keys'), }, ], @@ -1850,7 +1850,7 @@ U.Map = L.Map.extend({ const els = curr.split('|') acc[els[0]] = { "label": els[1] || els[0], - "type": els[2] || "checkbox" + "type": els[2] || "enum" } return acc }, {}) From 763341eacffb82116a86f3ad9a2eb40689d0cf25 Mon Sep 17 00:00:00 2001 From: flammermann Date: Tue, 2 Jan 2024 22:38:50 +0000 Subject: [PATCH 04/24] Support date properties in facet search - move parseDateField --- umap/static/umap/js/umap.controls.js | 2 +- umap/static/umap/js/umap.core.js | 60 ++++++++++++++++++++++++++++ umap/static/umap/js/umap.features.js | 12 +----- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 77e7d0f8..fc0bced2 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -695,7 +695,7 @@ const ControlsMixin = { keys.forEach((key) => { let value = feature.properties[key] if (facetKeys[key]["type"] === "date") { - value = feature.parseDateField(value) + value = L.Util.parseDateField(value) if (!!value && (!facetCriteria[key]["min"] || facetCriteria[key]["min"] > value)) { facetCriteria[key]["min"] = value } diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 4981086b..47468070 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -67,6 +67,66 @@ L.Util.setNullableBooleanFromQueryString = function (options, name) { } } +// the Date() constructor can handle various inputs to create a date +// - value: epoch (unix timestamp) in milliseconds +// - dateString: ISO 8601 formatted (YYYY-MM-DDTHH:mm:ss.sssZ) +// - dateObject: JS date object +// - multiple parameters for different date fields (year, month, ...) +// +// a mix of those options shall be supported without the user having +// to specify the exact format, since umap is based on json the type +// of the feature property value can only be +// - string, number or boolean +// - object or array +// - null +// +// therefore, the following inputs shall be covered +// - epoch (unix timestamp) in milliseconds as number +// - epoch (unix timestamp) in seconds as number +// - epoch (unix timestamp) in milliseconds as string +// - epoch (unix timestamp) in seconds as string +// - date in ISO 8601 format as string +// +// this function tries to guess the format of the feature property value +// and adjust it a little before passing it to Date() constructor +// +L.Util.parseDateField = function (value) { + if (value != null && parseFloat(value).toString() === value.toString()) { + // if the string representation of the feature property value is + // the same with and without being parsed as a float, the value is + // a number (either of type number or string) + // + // numbers are assumed to be epochs (unix timestamps) but so far + // it is unclear whether it is in seconds, milliseconds or nanoseconds + // + // without user input it can never be determined with certainty, but + // by making some assumptions and sacrificing some small date ranges, + // it is possible to work around that + // + value = parseFloat(value); + if (Math.abs(value) < 10000000000) { + // if the absolute value of that number is smaller than 10000000000, + // it is assumed to be in seconds and must be multiplied by 1000 + // + value = value * 1000; + } else if (Math.abs(value) > 10000000000000) { + // if the absolute value of that number is bigger than 10000000000000, + // it is assumed to be in nanoseconds and must be divided by 1000 + // + value = value / 1000; + } + // in all other cases the number is assumed to be in milliseconds + } + // in all other cases the value is passed to the Date() constructor + // without modification and the constructor will try to make something out of it + // + // this can either result in something proper (e.g. string containing dateString), + // something wrong (e.g. boolean) or an invalid date (e.g. object, array, null, + // string not containing dateString) + // + return new Date(value); +} + L.DomUtil.add = (tagName, className, container, content) => { const el = L.DomUtil.create(tagName, className, container) if (content) { diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 297da47b..d3c14dab 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -492,16 +492,6 @@ U.FeatureMixin = { return false }, - parseDateField: function (value) { - if (parseFloat(value).toString() === value.toString()) { - value = parseFloat(value); - if (value < 10000000000) { - value = value * 1000; - } - } - return new Date(value); - }, - matchFacets: function () { const facets = this.map.facets for (const [property, criteria] of Object.entries(facets)) { @@ -510,7 +500,7 @@ U.FeatureMixin = { if (type === "date") { const min = new Date(criteria["min"]) const max = new Date(criteria["max"]) - value = this.parseDateField(value) + value = L.Util.parseDateField(value) if (!!min && (!value || min > value)) return false if (!!max && (!value || max < value)) return false } else { From ce485f88f9f51877c1f3b946b94be31ce603ee91 Mon Sep 17 00:00:00 2001 From: flammermann Date: Tue, 2 Jan 2024 22:40:55 +0000 Subject: [PATCH 05/24] Support date properties in facet search - remove comments parseDateField --- umap/static/umap/js/umap.core.js | 48 -------------------------------- 1 file changed, 48 deletions(-) diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 47468070..64b20537 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -67,63 +67,15 @@ L.Util.setNullableBooleanFromQueryString = function (options, name) { } } -// the Date() constructor can handle various inputs to create a date -// - value: epoch (unix timestamp) in milliseconds -// - dateString: ISO 8601 formatted (YYYY-MM-DDTHH:mm:ss.sssZ) -// - dateObject: JS date object -// - multiple parameters for different date fields (year, month, ...) -// -// a mix of those options shall be supported without the user having -// to specify the exact format, since umap is based on json the type -// of the feature property value can only be -// - string, number or boolean -// - object or array -// - null -// -// therefore, the following inputs shall be covered -// - epoch (unix timestamp) in milliseconds as number -// - epoch (unix timestamp) in seconds as number -// - epoch (unix timestamp) in milliseconds as string -// - epoch (unix timestamp) in seconds as string -// - date in ISO 8601 format as string -// -// this function tries to guess the format of the feature property value -// and adjust it a little before passing it to Date() constructor -// L.Util.parseDateField = function (value) { if (value != null && parseFloat(value).toString() === value.toString()) { - // if the string representation of the feature property value is - // the same with and without being parsed as a float, the value is - // a number (either of type number or string) - // - // numbers are assumed to be epochs (unix timestamps) but so far - // it is unclear whether it is in seconds, milliseconds or nanoseconds - // - // without user input it can never be determined with certainty, but - // by making some assumptions and sacrificing some small date ranges, - // it is possible to work around that - // value = parseFloat(value); if (Math.abs(value) < 10000000000) { - // if the absolute value of that number is smaller than 10000000000, - // it is assumed to be in seconds and must be multiplied by 1000 - // value = value * 1000; } else if (Math.abs(value) > 10000000000000) { - // if the absolute value of that number is bigger than 10000000000000, - // it is assumed to be in nanoseconds and must be divided by 1000 - // value = value / 1000; } - // in all other cases the number is assumed to be in milliseconds } - // in all other cases the value is passed to the Date() constructor - // without modification and the constructor will try to make something out of it - // - // this can either result in something proper (e.g. string containing dateString), - // something wrong (e.g. boolean) or an invalid date (e.g. object, array, null, - // string not containing dateString) - // return new Date(value); } From 4042b86746a4e4f4e434c1c2919584fe0d7af3b1 Mon Sep 17 00:00:00 2001 From: flammermann Date: Tue, 2 Jan 2024 22:52:34 +0000 Subject: [PATCH 06/24] Support date properties in facet search - state allowed data types --- umap/static/umap/js/umap.core.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 64b20537..9df1c199 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -553,7 +553,7 @@ U.Help = L.Class.extend({ slugKey: L._('The name of the property to use as feature unique identifier.'), filterKey: L._('Comma separated list of properties to use when filtering features'), facetKey: L._( - 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control data type, add it after another | (eg.: mykey|My Key|enum,otherkey|Other Key|date)' + 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control data type, add it after another | (eg.: mykey|My Key|enum,otherkey|Other Key|date). Allowed values for the data type are date and enum (default).' ), interactive: L._( 'If false, the polygon or line will act as a part of the underlying map.' From 2c48b97b4bc5f403fc99c3e0ed11795df20c0966 Mon Sep 17 00:00:00 2001 From: flammermann Date: Tue, 2 Jan 2024 23:51:07 +0000 Subject: [PATCH 07/24] Support date properties in facet search - enhance UI --- umap/static/umap/js/umap.forms.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 575d5cab..9986b970 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -781,26 +781,35 @@ L.FormBuilder.FacetSearchCheckbox = L.FormBuilder.Element.extend({ L.FormBuilder.FacetSearchDate = L.FormBuilder.Element.extend({ build: function () { this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode); + this.table = L.DomUtil.create('table', '', this.container); + const min = this.options.criteria['min']; const max = this.options.criteria['max']; - // Create labels for min and max inputs - this.minLabel = L.DomUtil.create('label', '', this.container); - this.minLabel.innerHTML = 'Start'; + this.minTr = L.DomUtil.create('tr', '', this.table); + + this.minTdLabel = L.DomUtil.create('td', '', this.minTr); + this.minLabel = L.DomUtil.create('label', '', this.minTdLabel); + this.minLabel.innerHTML = 'From'; this.minLabel.htmlFor = `date_${this.name}_min`; - this.minInput = L.DomUtil.create('input', '', this.container); + this.minTdInput = L.DomUtil.create('td', '', this.minTr); + this.minInput = L.DomUtil.create('input', '', this.minTdInput); this.minInput.type = 'datetime-local'; this.minInput.step = '0.001'; this.minInput.id = `date_${this.name}_min`; this.minInput.valueAsNumber = (min.valueOf() - min.getTimezoneOffset() * 60000);; this.minInput.dataset.value = min; - this.maxLabel = L.DomUtil.create('label', '', this.container); - this.maxLabel.innerHTML = 'End'; + this.maxTr = L.DomUtil.create('tr', '', this.table); + + this.maxTdLabel = L.DomUtil.create('td', '', this.maxTr); + this.maxLabel = L.DomUtil.create('label', '', this.maxTdLabel); + this.maxLabel.innerHTML = 'Until'; this.maxLabel.htmlFor = `date_${this.name}_max`; - this.maxInput = L.DomUtil.create('input', '', this.container); + this.maxTdInput = L.DomUtil.create('td', '', this.maxTr); + this.maxInput = L.DomUtil.create('input', '', this.maxTdInput); this.maxInput.type = 'datetime-local'; this.maxInput.step = '0.001'; this.maxInput.id = `date_${this.name}_max`; From 839bb4c5d8b9eba00fac740cc407cd48f7901327 Mon Sep 17 00:00:00 2001 From: flammermann Date: Wed, 3 Jan 2024 02:25:10 +0000 Subject: [PATCH 08/24] Support date properties in facet search - split type into dataType and inputType --- umap/static/umap/base.css | 4 ++++ umap/static/umap/js/umap.controls.js | 14 ++++++++----- umap/static/umap/js/umap.core.js | 2 +- umap/static/umap/js/umap.features.js | 4 ++-- umap/static/umap/js/umap.forms.js | 30 ++++++++++++++++++---------- umap/static/umap/js/umap.js | 17 ++++++++++++++-- 6 files changed, 50 insertions(+), 21 deletions(-) diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index ec75c4a0..af8fa881 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -263,6 +263,10 @@ input[type="checkbox"] + label { display: inline; padding: 0 14px; } +input[type="radio"] + label { + display: inline; + padding: 0 14px; +} select + .error, input + .error { display: block; diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index fc0bced2..a3ddc527 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -669,22 +669,26 @@ const ControlsMixin = { const facetCriteria = {} keys.forEach((key) => { - if (facetKeys[key]["type"] === "date") { + if (facetKeys[key]["dataType"] === "date") { if (!facetCriteria[key]) facetCriteria[key] = { + "dataType": facetKeys[key]["dataType"], + "inputType": facetKeys[key]["inputType"], "min": undefined, "max": undefined } if (!this.facets[key]) this.facets[key] = { - "type": facetKeys[key]["type"], + "dataType": facetKeys[key]["dataType"], "min": undefined, "max": undefined } } else { if (!facetCriteria[key]) facetCriteria[key] = { + "dataType": facetKeys[key]["dataType"], + "inputType": facetKeys[key]["inputType"], "choices": [] } if (!this.facets[key]) this.facets[key] = { - "type": facetKeys[key]["type"], + "dataType": facetKeys[key]["dataType"], "choices": [] } } @@ -694,7 +698,7 @@ const ControlsMixin = { datalayer.eachFeature((feature) => { keys.forEach((key) => { let value = feature.properties[key] - if (facetKeys[key]["type"] === "date") { + if (facetKeys[key]["dataType"] === "date") { value = L.Util.parseDateField(value) if (!!value && (!facetCriteria[key]["min"] || facetCriteria[key]["min"] > value)) { facetCriteria[key]["min"] = value @@ -725,7 +729,7 @@ const ControlsMixin = { const fields = keys.map((key) => [ `facets.${key}`, { - handler: facetKeys[key]["type"] === "date" ? 'FacetSearchDate' : 'FacetSearchCheckbox', + handler: facetCriteria[key]["inputType"] === "datetime-local" ? 'FacetSearchDate' : 'FacetSearchCheckbox', criteria: facetCriteria[key], label: facetKeys[key]["label"] }, diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 9df1c199..055362ed 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -553,7 +553,7 @@ U.Help = L.Class.extend({ slugKey: L._('The name of the property to use as feature unique identifier.'), filterKey: L._('Comma separated list of properties to use when filtering features'), facetKey: L._( - 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control data type, add it after another | (eg.: mykey|My Key|enum,otherkey|Other Key|date). Allowed values for the data type are date and enum (default).' + 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control data type, add it after another | (eg.: mykey|My Key|enum,otherkey|Other Key|date). Allowed values for the data type are date and enum (default). To control input field type, add it after another | (eg.: mykey|My Key|enum|checkbox,otherkey|Other Key|date|datetime-local). Allowed values for the input field type are checkbox (default) or radio for data type enum and datetime-local (default) for data type date.' ), interactive: L._( 'If false, the polygon or line will act as a part of the underlying map.' diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index d3c14dab..09d23a3b 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -496,8 +496,8 @@ U.FeatureMixin = { const facets = this.map.facets for (const [property, criteria] of Object.entries(facets)) { let value = this.properties[property] - const type = criteria["type"] - if (type === "date") { + const dataType = criteria["dataType"] + if (dataType === "date") { const min = new Date(criteria["min"]) const max = new Date(criteria["max"]) value = L.Util.parseDateField(value) diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 9986b970..15eedd8d 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -748,6 +748,9 @@ L.FormBuilder.FacetSearchCheckbox = L.FormBuilder.Element.extend({ build: function () { this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode) this.ul = L.DomUtil.create('ul', '', this.container) + this.dataType = this.options.criteria["dataType"] + this.inputType = this.options.criteria["inputType"] + const choices = this.options.criteria["choices"] choices.sort() choices.forEach((value) => this.buildLi(value)) @@ -761,18 +764,21 @@ L.FormBuilder.FacetSearchCheckbox = L.FormBuilder.Element.extend({ const property_li = L.DomUtil.create('li', '', this.ul), input = L.DomUtil.create('input', '', property_li), label = L.DomUtil.create('label', '', property_li) - input.type = 'checkbox' - input.id = `checkbox_${this.name}_${value}` + + input.type = this.inputType + input.name = `${this.inputType}_${this.name}` + input.id = `${this.inputType}_${this.name}_${value}` input.checked = this.get()['choices'].includes(value) input.dataset.value = value - label.htmlFor = `checkbox_${this.name}_${value}` + label.htmlFor = `${this.inputType}_${this.name}_${value}` label.innerHTML = value + L.DomEvent.on(input, 'change', (e) => this.sync()) }, toJS: function () { return { - 'type': 'checkbox', + 'dataType': this.dataType, 'choices': [...this.ul.querySelectorAll('input:checked')].map((i) => i.dataset.value) } }, @@ -782,6 +788,8 @@ L.FormBuilder.FacetSearchDate = L.FormBuilder.Element.extend({ build: function () { this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode); this.table = L.DomUtil.create('table', '', this.container); + this.dataType = this.options.criteria["dataType"]; + this.inputType = this.options.criteria["inputType"]; const min = this.options.criteria['min']; const max = this.options.criteria['max']; @@ -791,13 +799,13 @@ L.FormBuilder.FacetSearchDate = L.FormBuilder.Element.extend({ this.minTdLabel = L.DomUtil.create('td', '', this.minTr); this.minLabel = L.DomUtil.create('label', '', this.minTdLabel); this.minLabel.innerHTML = 'From'; - this.minLabel.htmlFor = `date_${this.name}_min`; + this.minLabel.htmlFor = `${this.inputType}_${this.name}_min`; this.minTdInput = L.DomUtil.create('td', '', this.minTr); this.minInput = L.DomUtil.create('input', '', this.minTdInput); - this.minInput.type = 'datetime-local'; + this.minInput.type = this.inputType; this.minInput.step = '0.001'; - this.minInput.id = `date_${this.name}_min`; + this.minInput.id = `${this.inputType}_${this.name}_min`; this.minInput.valueAsNumber = (min.valueOf() - min.getTimezoneOffset() * 60000);; this.minInput.dataset.value = min; @@ -806,13 +814,13 @@ L.FormBuilder.FacetSearchDate = L.FormBuilder.Element.extend({ this.maxTdLabel = L.DomUtil.create('td', '', this.maxTr); this.maxLabel = L.DomUtil.create('label', '', this.maxTdLabel); this.maxLabel.innerHTML = 'Until'; - this.maxLabel.htmlFor = `date_${this.name}_max`; + this.maxLabel.htmlFor = `${this.inputType}_${this.name}_max`; this.maxTdInput = L.DomUtil.create('td', '', this.maxTr); this.maxInput = L.DomUtil.create('input', '', this.maxTdInput); - this.maxInput.type = 'datetime-local'; + this.maxInput.type = this.inputType; this.maxInput.step = '0.001'; - this.maxInput.id = `date_${this.name}_max`; + this.maxInput.id = `${this.inputType}_${this.name}_max`; this.maxInput.valueAsNumber = (max.valueOf() - max.getTimezoneOffset() * 60000);; this.maxInput.dataset.value = max; @@ -826,7 +834,7 @@ L.FormBuilder.FacetSearchDate = L.FormBuilder.Element.extend({ toJS: function () { return { - 'type': 'date', + 'dataType': this.dataType, 'min': this.minInput.value, 'max': this.maxInput.value, }; diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index bdd2959d..956d45bd 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -1236,7 +1236,7 @@ U.Map = L.Map.extend({ { handler: 'Input', helpEntries: 'facetKey', - placeholder: L._('Example: key1,key2|Label 2,key3|Label 3|enum'), + placeholder: L._('Example: key1,key2|Label 2,key3|Label 3|enum|checkbox'), label: L._('Facet keys'), }, ], @@ -1846,12 +1846,25 @@ U.Map = L.Map.extend({ }, getFacetKeys: function () { + const allowedTypes = { + "enum": ["checkbox", "radio"], + "date": ["datetime-local"], + } + console.log(this.options.facetKey) return (this.options.facetKey || '').split(',').reduce((acc, curr) => { const els = curr.split('|') acc[els[0]] = { "label": els[1] || els[0], - "type": els[2] || "enum" + "dataType": ( + (els[2] in allowedTypes) ? els[2] : + Object.keys(allowedTypes)[0] + ) } + acc[els[0]]["inputType"] = ( + allowedTypes[acc[els[0]]["dataType"]].includes(els[3]) ? els[3] : + allowedTypes[acc[els[0]]["dataType"]][0] + ) + console.log(acc) return acc }, {}) }, From b0c9edba7fe41baefee1aa464a27ad67402d7f6e Mon Sep 17 00:00:00 2001 From: flammermann Date: Thu, 4 Jan 2024 00:27:17 +0000 Subject: [PATCH 09/24] Support date and number properties in facet search - refactoring --- umap/static/umap/js/umap.controls.js | 25 ++++--- umap/static/umap/js/umap.core.js | 14 +--- umap/static/umap/js/umap.features.js | 26 +++++-- umap/static/umap/js/umap.forms.js | 106 +++++++++++++++++---------- umap/static/umap/js/umap.js | 21 +++--- 5 files changed, 112 insertions(+), 80 deletions(-) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index a3ddc527..5d77ffff 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -669,26 +669,25 @@ const ControlsMixin = { const facetCriteria = {} keys.forEach((key) => { - if (facetKeys[key]["dataType"] === "date") { + const inputType = facetKeys[key]["inputType"] + if (["date", "datetime-local", "number"].includes(inputType)) { if (!facetCriteria[key]) facetCriteria[key] = { - "dataType": facetKeys[key]["dataType"], "inputType": facetKeys[key]["inputType"], "min": undefined, "max": undefined } if (!this.facets[key]) this.facets[key] = { - "dataType": facetKeys[key]["dataType"], + "inputType": facetKeys[key]["inputType"], "min": undefined, "max": undefined } } else { if (!facetCriteria[key]) facetCriteria[key] = { - "dataType": facetKeys[key]["dataType"], "inputType": facetKeys[key]["inputType"], "choices": [] } if (!this.facets[key]) this.facets[key] = { - "dataType": facetKeys[key]["dataType"], + "inputType": facetKeys[key]["inputType"], "choices": [] } } @@ -698,15 +697,20 @@ const ControlsMixin = { datalayer.eachFeature((feature) => { keys.forEach((key) => { let value = feature.properties[key] - if (facetKeys[key]["dataType"] === "date") { - value = L.Util.parseDateField(value) - if (!!value && (!facetCriteria[key]["min"] || facetCriteria[key]["min"] > value)) { + const inputType = facetKeys[key]["inputType"] + if (["date", "datetime-local", "number"].includes(inputType)) { + value = (value != null ? value : undefined) + if (["date", "datetime-local"].includes(inputType)) value = new Date(value); + if (["number"].includes(inputType)) value = parseFloat(value); + if (!isNaN(value) && (isNaN(facetCriteria[key]["min"]) || facetCriteria[key]["min"] > value)) { facetCriteria[key]["min"] = value } - if (!!value && (!facetCriteria[key]["max"] || facetCriteria[key]["max"] < value)) { + if (!isNaN(value) && (isNaN(facetCriteria[key]["max"]) || facetCriteria[key]["max"] < value)) { facetCriteria[key]["max"] = value } } else { + value = String(value) + value = (value.length ? value : "empty string") if (!!value && !facetCriteria[key]["choices"].includes(value)) { facetCriteria[key]["choices"].push(value) } @@ -725,11 +729,10 @@ const ControlsMixin = { if (!found) this.ui.alert({ content: L._('No results for these facets'), level: 'info' }) } - const fields = keys.map((key) => [ `facets.${key}`, { - handler: facetCriteria[key]["inputType"] === "datetime-local" ? 'FacetSearchDate' : 'FacetSearchCheckbox', + handler: ["date", "datetime-local", "number"].includes(facetCriteria[key]["inputType"]) ? 'FacetSearchMinMax' : 'FacetSearchChoices', criteria: facetCriteria[key], label: facetKeys[key]["label"] }, diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 055362ed..06313859 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -67,18 +67,6 @@ L.Util.setNullableBooleanFromQueryString = function (options, name) { } } -L.Util.parseDateField = function (value) { - if (value != null && parseFloat(value).toString() === value.toString()) { - value = parseFloat(value); - if (Math.abs(value) < 10000000000) { - value = value * 1000; - } else if (Math.abs(value) > 10000000000000) { - value = value / 1000; - } - } - return new Date(value); -} - L.DomUtil.add = (tagName, className, container, content) => { const el = L.DomUtil.create(tagName, className, container) if (content) { @@ -553,7 +541,7 @@ U.Help = L.Class.extend({ slugKey: L._('The name of the property to use as feature unique identifier.'), filterKey: L._('Comma separated list of properties to use when filtering features'), facetKey: L._( - 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control data type, add it after another | (eg.: mykey|My Key|enum,otherkey|Other Key|date). Allowed values for the data type are date and enum (default). To control input field type, add it after another | (eg.: mykey|My Key|enum|checkbox,otherkey|Other Key|date|datetime-local). Allowed values for the input field type are checkbox (default) or radio for data type enum and datetime-local (default) for data type date.' + 'Comma separated list of properties to use for facet search (eg.: mykey,otherkey). To control label, add it after a | (eg.: mykey|My Key,otherkey|Other Key). To control input field type, add it after another | (eg.: mykey|My Key|checkbox,otherkey|Other Key|datetime). Allowed values for the input field type are checkbox (default), radio, number, date and datetime.' ), interactive: L._( 'If false, the polygon or line will act as a part of the underlying map.' diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 09d23a3b..96688a04 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -496,15 +496,27 @@ U.FeatureMixin = { const facets = this.map.facets for (const [property, criteria] of Object.entries(facets)) { let value = this.properties[property] - const dataType = criteria["dataType"] - if (dataType === "date") { - const min = new Date(criteria["min"]) - const max = new Date(criteria["max"]) - value = L.Util.parseDateField(value) - if (!!min && (!value || min > value)) return false - if (!!max && (!value || max < value)) return false + const inputType = criteria["inputType"] + if (["date", "datetime-local", "number"].includes(inputType)) { + let min = criteria["min"] + let max = criteria["max"] + value = (value != null ? value : undefined) + if (["date", "datetime-local"].includes(inputType)) { + min = new Date(min) + max = new Date(max) + value = new Date(value) + } + if (["number"].includes(inputType)) { + min = parseFloat(min) + max = parseFloat(max) + value = parseFloat(value) + } + if (!isNaN(min) && !isNaN(value) && min > value) return false + if (!isNaN(max) && !isNaN(value) && max < value) return false } else { const choices = criteria["choices"] + value = String(value) + value = (value.length ? value : "empty string") if (choices.length && (!value || !choices.includes(value))) return false } } diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 15eedd8d..63a36592 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -744,11 +744,10 @@ L.FormBuilder.Switch = L.FormBuilder.CheckBox.extend({ }, }) -L.FormBuilder.FacetSearchCheckbox = L.FormBuilder.Element.extend({ +L.FormBuilder.FacetSearchChoices = L.FormBuilder.Element.extend({ build: function () { this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode) this.ul = L.DomUtil.create('ul', '', this.container) - this.dataType = this.options.criteria["dataType"] this.inputType = this.options.criteria["inputType"] const choices = this.options.criteria["choices"] @@ -778,54 +777,87 @@ L.FormBuilder.FacetSearchCheckbox = L.FormBuilder.Element.extend({ toJS: function () { return { - 'dataType': this.dataType, + 'inputType': this.inputType, 'choices': [...this.ul.querySelectorAll('input:checked')].map((i) => i.dataset.value) } }, }) -L.FormBuilder.FacetSearchDate = L.FormBuilder.Element.extend({ +L.FormBuilder.FacetSearchMinMax = L.FormBuilder.Element.extend({ build: function () { - this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode); - this.table = L.DomUtil.create('table', '', this.container); - this.dataType = this.options.criteria["dataType"]; - this.inputType = this.options.criteria["inputType"]; + this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode) + this.table = L.DomUtil.create('table', '', this.container) + this.inputType = this.options.criteria["inputType"] - const min = this.options.criteria['min']; - const max = this.options.criteria['max']; + const min = this.options.criteria['min'] + const max = this.options.criteria['max'] - this.minTr = L.DomUtil.create('tr', '', this.table); + this.minTr = L.DomUtil.create('tr', '', this.table) - this.minTdLabel = L.DomUtil.create('td', '', this.minTr); - this.minLabel = L.DomUtil.create('label', '', this.minTdLabel); - this.minLabel.innerHTML = 'From'; - this.minLabel.htmlFor = `${this.inputType}_${this.name}_min`; + this.minTdLabel = L.DomUtil.create('td', '', this.minTr) + this.minLabel = L.DomUtil.create('label', '', this.minTdLabel) + this.minLabel.innerHTML = 'Min' + this.minLabel.htmlFor = `${this.inputType}_${this.name}_min` - this.minTdInput = L.DomUtil.create('td', '', this.minTr); - this.minInput = L.DomUtil.create('input', '', this.minTdInput); - this.minInput.type = this.inputType; - this.minInput.step = '0.001'; - this.minInput.id = `${this.inputType}_${this.name}_min`; - this.minInput.valueAsNumber = (min.valueOf() - min.getTimezoneOffset() * 60000);; - this.minInput.dataset.value = min; + this.minTdInput = L.DomUtil.create('td', '', this.minTr) + this.minInput = L.DomUtil.create('input', '', this.minTdInput) + this.minInput.type = this.inputType + this.minInput.id = `${this.inputType}_${this.name}_min` + this.minInput.step = '1' + if (min != null) { + this.minInput.valueAsNumber = min.valueOf() + this.minInput.dataset.value = min + } - this.maxTr = L.DomUtil.create('tr', '', this.table); + this.maxTr = L.DomUtil.create('tr', '', this.table) - this.maxTdLabel = L.DomUtil.create('td', '', this.maxTr); - this.maxLabel = L.DomUtil.create('label', '', this.maxTdLabel); - this.maxLabel.innerHTML = 'Until'; - this.maxLabel.htmlFor = `${this.inputType}_${this.name}_max`; + this.maxTdLabel = L.DomUtil.create('td', '', this.maxTr) + this.maxLabel = L.DomUtil.create('label', '', this.maxTdLabel) + this.maxLabel.innerHTML = 'Max' + this.maxLabel.htmlFor = `${this.inputType}_${this.name}_max` - this.maxTdInput = L.DomUtil.create('td', '', this.maxTr); - this.maxInput = L.DomUtil.create('input', '', this.maxTdInput); - this.maxInput.type = this.inputType; - this.maxInput.step = '0.001'; - this.maxInput.id = `${this.inputType}_${this.name}_max`; - this.maxInput.valueAsNumber = (max.valueOf() - max.getTimezoneOffset() * 60000);; - this.maxInput.dataset.value = max; + this.maxTdInput = L.DomUtil.create('td', '', this.maxTr) + this.maxInput = L.DomUtil.create('input', '', this.maxTdInput) + this.maxInput.type = this.inputType + this.maxInput.id = `${this.inputType}_${this.name}_max` + this.maxInput.step = '1' + if (max != null) { + this.maxInput.valueAsNumber = max.valueOf() + this.maxInput.dataset.value = max + } - L.DomEvent.on(this.minInput, 'change', (e) => this.sync()); - L.DomEvent.on(this.maxInput, 'change', (e) => this.sync()); + if (["date", "datetime-local"].includes(this.inputType)) { + this.minLabel.innerHTML = 'From' + this.maxLabel.innerHTML = 'Until' + if (min != null) { + this.minInput.valueAsNumber = (min.valueOf() - min.getTimezoneOffset() * 60000) + } + if (max != null) { + this.maxInput.valueAsNumber = (max.valueOf() - max.getTimezoneOffset() * 60000) + } + } + + if (["datetime-local"].includes(this.inputType)) { + this.minInput.step = '0.001' + this.maxInput.step = '0.001' + } + + if (["number"].includes(this.inputType)) { + if (min != null && max != null) { + // calculate step from significant digits of min and max values + let minStep = String(min).replace(/^\d+?(0*)((\.)(\d*?)0*|)$/, "1$1$3$4").split('.') + let maxStep = String(max).replace(/^\d+?(0*)((\.)(\d*?)0*|)$/, "1$1$3$4").split('.') + minStep = parseFloat((minStep[1] || "").replace(/\d/g, "0").replace(/^0/, "0.0").replace(/0$/, "1") || (minStep[0] || "").replace(/0$/, "") || "1") + maxStep = parseFloat((maxStep[1] || "").replace(/\d/g, "0").replace(/^0/, "0.0").replace(/0$/, "1") || (maxStep[0] || "").replace(/0$/, "") || "1") + + const step = Math.min(minStep, maxStep) + this.minInput.step = String(step) + this.maxInput.step = String(step) + } + } + + L.DomEvent.on(this.minInput, 'change', (e) => this.sync()) + L.DomEvent.on(this.maxInput, 'change', (e) => this.sync()) }, buildLabel: function () { @@ -834,7 +866,7 @@ L.FormBuilder.FacetSearchDate = L.FormBuilder.Element.extend({ toJS: function () { return { - 'dataType': this.dataType, + 'inputType': this.inputType, 'min': this.minInput.value, 'max': this.maxInput.value, }; diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 956d45bd..12e1714e 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -1846,25 +1846,22 @@ U.Map = L.Map.extend({ }, getFacetKeys: function () { - const allowedTypes = { - "enum": ["checkbox", "radio"], - "date": ["datetime-local"], + const allowedInputTypes = { + "checkbox": "checkbox", + "radio": "radio", + "number": "number", + "date": "date", + "datetime": "datetime-local", } - console.log(this.options.facetKey) return (this.options.facetKey || '').split(',').reduce((acc, curr) => { const els = curr.split('|') acc[els[0]] = { "label": els[1] || els[0], - "dataType": ( - (els[2] in allowedTypes) ? els[2] : - Object.keys(allowedTypes)[0] + "inputType": ( + (els[2] in allowedInputTypes) ? allowedInputTypes[els[2]] : + Object.values(allowedInputTypes)[0] ) } - acc[els[0]]["inputType"] = ( - allowedTypes[acc[els[0]]["dataType"]].includes(els[3]) ? els[3] : - allowedTypes[acc[els[0]]["dataType"]][0] - ) - console.log(acc) return acc }, {}) }, From 10b1bc6265c6d0272a2782bb05afaaf77cdd1e9d Mon Sep 17 00:00:00 2001 From: flammermann Date: Thu, 4 Jan 2024 00:37:59 +0000 Subject: [PATCH 10/24] Support date and number properties in facet search - mini fix in placeholder --- umap/static/umap/js/umap.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 12e1714e..5d7244a4 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -1236,7 +1236,7 @@ U.Map = L.Map.extend({ { handler: 'Input', helpEntries: 'facetKey', - placeholder: L._('Example: key1,key2|Label 2,key3|Label 3|enum|checkbox'), + placeholder: L._('Example: key1,key2|Label 2,key3|Label 3|checkbox'), label: L._('Facet keys'), }, ], From f4413a8a7451b3ad779bbb05244074c1d01fa1c7 Mon Sep 17 00:00:00 2001 From: flammermann Date: Sun, 3 Mar 2024 13:28:03 +0000 Subject: [PATCH 11/24] translation capability and small cleanups --- umap/static/umap/js/umap.controls.js | 25 +++++++++++++++---------- umap/static/umap/js/umap.core.js | 7 +++++++ umap/static/umap/js/umap.features.js | 2 +- umap/static/umap/js/umap.forms.js | 15 +++++---------- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 5d77ffff..f8d6e4e5 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -710,7 +710,7 @@ const ControlsMixin = { } } else { value = String(value) - value = (value.length ? value : "empty string") + value = (value.length ? value : L._("empty string")) if (!!value && !facetCriteria[key]["choices"].includes(value)) { facetCriteria[key]["choices"].push(value) } @@ -729,15 +729,20 @@ const ControlsMixin = { if (!found) this.ui.alert({ content: L._('No results for these facets'), level: 'info' }) } - const fields = keys.map((key) => [ - `facets.${key}`, - { - handler: ["date", "datetime-local", "number"].includes(facetCriteria[key]["inputType"]) ? 'FacetSearchMinMax' : 'FacetSearchChoices', - criteria: facetCriteria[key], - label: facetKeys[key]["label"] - }, - ]) - const builder = new U.FormBuilder(this, fields, { + const fields = keys.map((key) => { + let criteria = facetCriteria[key] + let handler = ["date", "datetime-local", "number"].includes(criteria["inputType"]) ? 'FacetSearchMinMax' : 'FacetSearchChoices' + let label = facetKeys[key]["label"] + return [ + `facets.${key}`, + { + criteria: criteria, + handler: handler, + label: label + }, + ] + }) + const builder = new L.U.FormBuilder(this, fields, { makeDirty: false, callback: filterFeatures, callbackContext: this, diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 06313859..b9b7f659 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -67,6 +67,13 @@ L.Util.setNullableBooleanFromQueryString = function (options, name) { } } +L.Util.calculateStepFromNumber = function (n) { + // calculate step for number input field from significant digits of number + let step = String(n).replace(/^\d+?(0*)((\.)(\d*?)0*|)$/, "1$1$3$4").split('.') + step = parseFloat((step[1] || "").replace(/\d/g, "0").replace(/^0/, "0.0").replace(/0$/, "1") || (step[0] || "").replace(/0$/, "") || "1") + return step +} + L.DomUtil.add = (tagName, className, container, content) => { const el = L.DomUtil.create(tagName, className, container) if (content) { diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 96688a04..5ec54115 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -516,7 +516,7 @@ U.FeatureMixin = { } else { const choices = criteria["choices"] value = String(value) - value = (value.length ? value : "empty string") + value = (value.length ? value : L._("empty string")) if (choices.length && (!value || !choices.includes(value))) return false } } diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 63a36592..cb9ce2c7 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -796,7 +796,7 @@ L.FormBuilder.FacetSearchMinMax = L.FormBuilder.Element.extend({ this.minTdLabel = L.DomUtil.create('td', '', this.minTr) this.minLabel = L.DomUtil.create('label', '', this.minTdLabel) - this.minLabel.innerHTML = 'Min' + this.minLabel.innerHTML = L._('Min') this.minLabel.htmlFor = `${this.inputType}_${this.name}_min` this.minTdInput = L.DomUtil.create('td', '', this.minTr) @@ -813,7 +813,7 @@ L.FormBuilder.FacetSearchMinMax = L.FormBuilder.Element.extend({ this.maxTdLabel = L.DomUtil.create('td', '', this.maxTr) this.maxLabel = L.DomUtil.create('label', '', this.maxTdLabel) - this.maxLabel.innerHTML = 'Max' + this.maxLabel.innerHTML = L._('Max') this.maxLabel.htmlFor = `${this.inputType}_${this.name}_max` this.maxTdInput = L.DomUtil.create('td', '', this.maxTr) @@ -827,8 +827,8 @@ L.FormBuilder.FacetSearchMinMax = L.FormBuilder.Element.extend({ } if (["date", "datetime-local"].includes(this.inputType)) { - this.minLabel.innerHTML = 'From' - this.maxLabel.innerHTML = 'Until' + this.minLabel.innerHTML = L._('From') + this.maxLabel.innerHTML = L._('Until') if (min != null) { this.minInput.valueAsNumber = (min.valueOf() - min.getTimezoneOffset() * 60000) } @@ -845,12 +845,7 @@ L.FormBuilder.FacetSearchMinMax = L.FormBuilder.Element.extend({ if (["number"].includes(this.inputType)) { if (min != null && max != null) { // calculate step from significant digits of min and max values - let minStep = String(min).replace(/^\d+?(0*)((\.)(\d*?)0*|)$/, "1$1$3$4").split('.') - let maxStep = String(max).replace(/^\d+?(0*)((\.)(\d*?)0*|)$/, "1$1$3$4").split('.') - minStep = parseFloat((minStep[1] || "").replace(/\d/g, "0").replace(/^0/, "0.0").replace(/0$/, "1") || (minStep[0] || "").replace(/0$/, "") || "1") - maxStep = parseFloat((maxStep[1] || "").replace(/\d/g, "0").replace(/^0/, "0.0").replace(/0$/, "1") || (maxStep[0] || "").replace(/0$/, "") || "1") - - const step = Math.min(minStep, maxStep) + const step = Math.min(L.Util.calculateStepFromNumber(min), L.Util.calculateStepFromNumber(max)) this.minInput.step = String(step) this.maxInput.step = String(step) } From 39857decda4a9eb3c019f8ebdfbc85d3f3e436d5 Mon Sep 17 00:00:00 2001 From: flammermann Date: Sun, 3 Mar 2024 13:51:52 +0000 Subject: [PATCH 12/24] resolve merge conflict --- umap/static/umap/js/umap.controls.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index f8d6e4e5..c9e709b2 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -742,7 +742,7 @@ const ControlsMixin = { }, ] }) - const builder = new L.U.FormBuilder(this, fields, { + const builder = new U.FormBuilder(this, fields, { makeDirty: false, callback: filterFeatures, callbackContext: this, From 47c6473285d31e06d8b168227cfea890a51f9589 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 17 Apr 2024 17:16:30 +0200 Subject: [PATCH 13/24] chore: refactor facet date and number HTML widgets --- umap/static/umap/base.css | 12 ++- umap/static/umap/js/umap.controls.js | 94 ++++++++++------- umap/static/umap/js/umap.core.js | 7 -- umap/static/umap/js/umap.features.js | 10 +- umap/static/umap/js/umap.forms.js | 149 +++++++++++++-------------- umap/static/umap/js/umap.js | 22 ++-- umap/static/umap/map.css | 1 - 7 files changed, 151 insertions(+), 144 deletions(-) diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index af8fa881..dfeb9b02 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -135,7 +135,7 @@ ul { /* forms */ /* *********** */ input[type="text"], input[type="password"], input[type="date"], -input[type="datetime"], input[type="email"], input[type="number"], +input[type="datetime-local"], input[type="email"], input[type="number"], input[type="search"], input[type="tel"], input[type="time"], input[type="url"], textarea { background-color: white; @@ -263,9 +263,8 @@ input[type="checkbox"] + label { display: inline; padding: 0 14px; } -input[type="radio"] + label { - display: inline; - padding: 0 14px; +label input[type="radio"] { + margin-right: 10px; } select + .error, input + .error { @@ -347,6 +346,11 @@ input:invalid { .fieldset.toggle.on .legend:before { background-position: -144px -51px; } +fieldset legend { + font-size: 1.1rem; + padding: 0 5px; +} +} /* Switch */ input.switch:empty { display: none; diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index c9e709b2..884633dc 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -669,27 +669,31 @@ const ControlsMixin = { const facetCriteria = {} keys.forEach((key) => { - const inputType = facetKeys[key]["inputType"] - if (["date", "datetime-local", "number"].includes(inputType)) { - if (!facetCriteria[key]) facetCriteria[key] = { - "inputType": facetKeys[key]["inputType"], - "min": undefined, - "max": undefined - } - if (!this.facets[key]) this.facets[key] = { - "inputType": facetKeys[key]["inputType"], - "min": undefined, - "max": undefined - } + const type = facetKeys[key]['type'] + if (['date', 'datetime', 'number'].includes(type)) { + if (!facetCriteria[key]) + facetCriteria[key] = { + type: facetKeys[key]['type'], + min: undefined, + max: undefined, + } + if (!this.facets[key]) + this.facets[key] = { + type: facetKeys[key]['type'], + min: undefined, + max: undefined, + } } else { - if (!facetCriteria[key]) facetCriteria[key] = { - "inputType": facetKeys[key]["inputType"], - "choices": [] - } - if (!this.facets[key]) this.facets[key] = { - "inputType": facetKeys[key]["inputType"], - "choices": [] - } + if (!facetCriteria[key]) + facetCriteria[key] = { + type: facetKeys[key]['type'], + choices: [], + } + if (!this.facets[key]) + this.facets[key] = { + type: facetKeys[key]['type'], + choices: [], + } } }) @@ -697,22 +701,28 @@ const ControlsMixin = { datalayer.eachFeature((feature) => { keys.forEach((key) => { let value = feature.properties[key] - const inputType = facetKeys[key]["inputType"] - if (["date", "datetime-local", "number"].includes(inputType)) { - value = (value != null ? value : undefined) - if (["date", "datetime-local"].includes(inputType)) value = new Date(value); - if (["number"].includes(inputType)) value = parseFloat(value); - if (!isNaN(value) && (isNaN(facetCriteria[key]["min"]) || facetCriteria[key]["min"] > value)) { - facetCriteria[key]["min"] = value + const type = facetKeys[key]['type'] + if (['date', 'datetime', 'number'].includes(type)) { + value = value != null ? value : undefined + if (['date', 'datetime'].includes(type)) value = new Date(value) + if (['number'].includes(type)) value = parseFloat(value) + if ( + !isNaN(value) && + (isNaN(facetCriteria[key]['min']) || facetCriteria[key]['min'] > value) + ) { + facetCriteria[key]['min'] = value } - if (!isNaN(value) && (isNaN(facetCriteria[key]["max"]) || facetCriteria[key]["max"] < value)) { - facetCriteria[key]["max"] = value + if ( + !isNaN(value) && + (isNaN(facetCriteria[key]['max']) || facetCriteria[key]['max'] < value) + ) { + facetCriteria[key]['max'] = value } } else { value = String(value) - value = (value.length ? value : L._("empty string")) - if (!!value && !facetCriteria[key]["choices"].includes(value)) { - facetCriteria[key]["choices"].push(value) + value = value.length ? value : L._('empty string') + if (!!value && !facetCriteria[key]['choices'].includes(value)) { + facetCriteria[key]['choices'].push(value) } } }) @@ -726,19 +736,31 @@ const ControlsMixin = { if (datalayer.hasDataVisible()) found = true }) // TODO: display a results counter in the panel instead. - if (!found) + if (!found) { this.ui.alert({ content: L._('No results for these facets'), level: 'info' }) + } } const fields = keys.map((key) => { let criteria = facetCriteria[key] - let handler = ["date", "datetime-local", "number"].includes(criteria["inputType"]) ? 'FacetSearchMinMax' : 'FacetSearchChoices' - let label = facetKeys[key]["label"] + let handler = 'FacetSearchChoices' + switch (criteria['type']) { + case 'number': + handler = 'FacetSearchNumber' + break + case 'date': + handler = 'FacetSearchDate' + break + case 'datetime': + handler = 'FacetSearchDateTime' + break + } + let label = facetKeys[key]['label'] return [ `facets.${key}`, { criteria: criteria, handler: handler, - label: label + label: label, }, ] }) diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index b9b7f659..06313859 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -67,13 +67,6 @@ L.Util.setNullableBooleanFromQueryString = function (options, name) { } } -L.Util.calculateStepFromNumber = function (n) { - // calculate step for number input field from significant digits of number - let step = String(n).replace(/^\d+?(0*)((\.)(\d*?)0*|)$/, "1$1$3$4").split('.') - step = parseFloat((step[1] || "").replace(/\d/g, "0").replace(/^0/, "0.0").replace(/0$/, "1") || (step[0] || "").replace(/0$/, "") || "1") - return step -} - L.DomUtil.add = (tagName, className, container, content) => { const el = L.DomUtil.create(tagName, className, container) if (content) { diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 5ec54115..b6fce1b7 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -496,17 +496,17 @@ U.FeatureMixin = { const facets = this.map.facets for (const [property, criteria] of Object.entries(facets)) { let value = this.properties[property] - const inputType = criteria["inputType"] - if (["date", "datetime-local", "number"].includes(inputType)) { + const type = criteria["type"] + if (["date", "datetime", "number"].includes(type)) { let min = criteria["min"] let max = criteria["max"] value = (value != null ? value : undefined) - if (["date", "datetime-local"].includes(inputType)) { + if (["date", "datetime"].includes(type)) { min = new Date(min) max = new Date(max) value = new Date(value) } - if (["number"].includes(inputType)) { + if (["number"].includes(type)) { min = parseFloat(min) max = parseFloat(max) value = parseFloat(value) @@ -517,7 +517,7 @@ U.FeatureMixin = { const choices = criteria["choices"] value = String(value) value = (value.length ? value : L._("empty string")) - if (choices.length && (!value || !choices.includes(value))) return false + if (choices?.length && (!value || !choices.includes(value))) return false } } return true diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index cb9ce2c7..42ab4c1a 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -527,11 +527,11 @@ L.FormBuilder.IconUrl = L.FormBuilder.BlurInput.extend({ ) } const symbol = L.DomUtil.add( - 'button', - 'flat tab-symbols', - this.tabs, - L._('Symbol') - ), + 'button', + 'flat tab-symbols', + this.tabs, + L._('Symbol') + ), char = L.DomUtil.add( 'button', 'flat tab-chars', @@ -746,127 +746,122 @@ L.FormBuilder.Switch = L.FormBuilder.CheckBox.extend({ L.FormBuilder.FacetSearchChoices = L.FormBuilder.Element.extend({ build: function () { - this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode) + this.container = L.DomUtil.create('fieldset', 'umap-facet', this.parentNode) + this.container.appendChild(this.label) this.ul = L.DomUtil.create('ul', '', this.container) - this.inputType = this.options.criteria["inputType"] + this.type = this.options.criteria['type'] - const choices = this.options.criteria["choices"] + const choices = this.options.criteria['choices'] choices.sort() choices.forEach((value) => this.buildLi(value)) }, buildLabel: function () { - this.label = L.DomUtil.add('h5', '', this.parentNode, this.options.label) + this.label = L.DomUtil.element('legend', {textContent: this.options.label}) }, buildLi: function (value) { - const property_li = L.DomUtil.create('li', '', this.ul), - input = L.DomUtil.create('input', '', property_li), - label = L.DomUtil.create('label', '', property_li) + const property_li = L.DomUtil.create('li', '', this.ul) + const label = L.DomUtil.add('label', '', property_li) + const input = L.DomUtil.create('input', '', label) + L.DomUtil.add('span', '', label, value) - input.type = this.inputType - input.name = `${this.inputType}_${this.name}` - input.id = `${this.inputType}_${this.name}_${value}` + input.type = this.type + input.name = `${this.type}_${this.name}` input.checked = this.get()['choices'].includes(value) input.dataset.value = value - label.htmlFor = `${this.inputType}_${this.name}_${value}` - label.innerHTML = value L.DomEvent.on(input, 'change', (e) => this.sync()) }, toJS: function () { return { - 'inputType': this.inputType, - 'choices': [...this.ul.querySelectorAll('input:checked')].map((i) => i.dataset.value) + type: this.type, + choices: [...this.ul.querySelectorAll('input:checked')].map( + (i) => i.dataset.value + ), } }, }) -L.FormBuilder.FacetSearchMinMax = L.FormBuilder.Element.extend({ +L.FormBuilder.MinMaxBase = L.FormBuilder.Element.extend({ + getInputType: function (type) { + return type + }, + + getLabels: function () { + return [L._('Min'), L._('Max')] + }, + + castValue: function (value) { + return value.valueOf() + }, + build: function () { - this.container = L.DomUtil.create('div', 'umap-facet', this.parentNode) - this.table = L.DomUtil.create('table', '', this.container) - this.inputType = this.options.criteria["inputType"] + this.container = L.DomUtil.create('fieldset', 'umap-facet', this.parentNode) + this.container.appendChild(this.label) + const {min, max, type} = this.options.criteria + this.type = type + this.inputType = this.getInputType(this.type) - const min = this.options.criteria['min'] - const max = this.options.criteria['max'] + const [minLabel, maxLabel] = this.getLabels() - this.minTr = L.DomUtil.create('tr', '', this.table) + this.minLabel = L.DomUtil.create('label', '', this.container) + this.minLabel.innerHTML = minLabel - this.minTdLabel = L.DomUtil.create('td', '', this.minTr) - this.minLabel = L.DomUtil.create('label', '', this.minTdLabel) - this.minLabel.innerHTML = L._('Min') - this.minLabel.htmlFor = `${this.inputType}_${this.name}_min` - - this.minTdInput = L.DomUtil.create('td', '', this.minTr) - this.minInput = L.DomUtil.create('input', '', this.minTdInput) + this.minInput = L.DomUtil.create('input', '', this.minLabel) this.minInput.type = this.inputType - this.minInput.id = `${this.inputType}_${this.name}_min` - this.minInput.step = '1' + this.minInput.step = 'any' if (min != null) { - this.minInput.valueAsNumber = min.valueOf() + this.minInput.valueAsNumber = this.castValue(min) this.minInput.dataset.value = min } - this.maxTr = L.DomUtil.create('tr', '', this.table) - this.maxTdLabel = L.DomUtil.create('td', '', this.maxTr) - this.maxLabel = L.DomUtil.create('label', '', this.maxTdLabel) - this.maxLabel.innerHTML = L._('Max') - this.maxLabel.htmlFor = `${this.inputType}_${this.name}_max` + this.maxLabel = L.DomUtil.create('label', '', this.container) + this.maxLabel.innerHTML = maxLabel - this.maxTdInput = L.DomUtil.create('td', '', this.maxTr) - this.maxInput = L.DomUtil.create('input', '', this.maxTdInput) + this.maxInput = L.DomUtil.create('input', '', this.maxLabel) this.maxInput.type = this.inputType - this.maxInput.id = `${this.inputType}_${this.name}_max` - this.maxInput.step = '1' + this.maxInput.step = 'any' if (max != null) { - this.maxInput.valueAsNumber = max.valueOf() + this.maxInput.valueAsNumber = this.castValue(max) this.maxInput.dataset.value = max } - if (["date", "datetime-local"].includes(this.inputType)) { - this.minLabel.innerHTML = L._('From') - this.maxLabel.innerHTML = L._('Until') - if (min != null) { - this.minInput.valueAsNumber = (min.valueOf() - min.getTimezoneOffset() * 60000) - } - if (max != null) { - this.maxInput.valueAsNumber = (max.valueOf() - max.getTimezoneOffset() * 60000) - } - } - - if (["datetime-local"].includes(this.inputType)) { - this.minInput.step = '0.001' - this.maxInput.step = '0.001' - } - - if (["number"].includes(this.inputType)) { - if (min != null && max != null) { - // calculate step from significant digits of min and max values - const step = Math.min(L.Util.calculateStepFromNumber(min), L.Util.calculateStepFromNumber(max)) - this.minInput.step = String(step) - this.maxInput.step = String(step) - } - } - L.DomEvent.on(this.minInput, 'change', (e) => this.sync()) L.DomEvent.on(this.maxInput, 'change', (e) => this.sync()) }, buildLabel: function () { - this.label = L.DomUtil.add('h5', '', this.parentNode, this.options.label) + this.label = L.DomUtil.element('legend', {textContent: this.options.label}) }, toJS: function () { return { - 'inputType': this.inputType, - 'min': this.minInput.value, - 'max': this.maxInput.value, - }; + type: this.type, + min: this.minInput.value, + max: this.maxInput.value, + } }, -}); +}) + +L.FormBuilder.FacetSearchNumber = L.FormBuilder.MinMaxBase.extend({}) + +L.FormBuilder.FacetSearchDate = L.FormBuilder.MinMaxBase.extend({ + castValue: function (value) { + return value.valueOf() - value.getTimezoneOffset() * 60000 + }, + getLabels: function () { + return [L._('From'), L._('Until')] + }, +}) + +L.FormBuilder.FacetSearchDateTime = L.FormBuilder.FacetSearchDate.extend({ + getInputType: function (type) { + return 'datetime-local' + }, +}) L.FormBuilder.MultiChoice = L.FormBuilder.Element.extend({ default: 'null', diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 5d7244a4..7f4bdc90 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -218,6 +218,7 @@ U.Map = L.Map.extend({ this.panel.mode = 'condensed' this.displayCaption() } else if (['facet', 'datafilters'].includes(this.options.onLoadPanel)) { + this.panel.mode = 'expanded' this.openFacet() } if (L.Util.queryString('edit')) { @@ -1846,21 +1847,14 @@ U.Map = L.Map.extend({ }, getFacetKeys: function () { - const allowedInputTypes = { - "checkbox": "checkbox", - "radio": "radio", - "number": "number", - "date": "date", - "datetime": "datetime-local", - } + const defaultType = 'checkbox' + const allowedTypes = [defaultType, 'radio', 'number', 'date', 'datetime'] return (this.options.facetKey || '').split(',').reduce((acc, curr) => { - const els = curr.split('|') - acc[els[0]] = { - "label": els[1] || els[0], - "inputType": ( - (els[2] in allowedInputTypes) ? allowedInputTypes[els[2]] : - Object.values(allowedInputTypes)[0] - ) + let [key, label, type] = curr.split('|') + type = allowedTypes.includes(type) ? type : defaultType + acc[key] = { + label: label || key, + type: type, } return acc }, {}) diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index 4d5c5677..553f6047 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -927,7 +927,6 @@ a.umap-control-caption, overflow: hidden; text-overflow: ellipsis; } -.umap-facet-search li:nth-child(even), .umap-browser .datalayer li:nth-child(even) { background-color: #efefef; } From 53f93ee97e8bb7a6988a52cf58996c479c944afd Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 17 Apr 2024 18:17:44 +0200 Subject: [PATCH 14/24] chore: move facets to a dedicated module --- umap/static/umap/js/modules/facets.js | 126 ++++++++++++++++++++++++++ umap/static/umap/js/modules/global.js | 2 + umap/static/umap/js/umap.controls.js | 112 +---------------------- umap/static/umap/js/umap.core.js | 2 +- umap/static/umap/js/umap.features.js | 5 +- umap/static/umap/js/umap.js | 16 +--- 6 files changed, 133 insertions(+), 130 deletions(-) create mode 100644 umap/static/umap/js/modules/facets.js diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js new file mode 100644 index 00000000..d141e1de --- /dev/null +++ b/umap/static/umap/js/modules/facets.js @@ -0,0 +1,126 @@ +import { DomUtil, DomEvent, stamp } from '../../vendors/leaflet/leaflet-src.esm.js' +import { translate } from './i18n.js' + +export default class Facets { + constructor(map) { + this.map = map + this.selected = {} + } + + compute(names, defined) { + const properties = {} + + names.forEach((name) => { + const type = defined[name]['type'] + properties[name] = { type: type } + this.selected[name] = { type: type } + if (!['date', 'datetime', 'number'].includes(type)) { + properties[name].choices = [] + this.selected[name].choices = [] + } + }) + + this.map.eachBrowsableDataLayer((datalayer) => { + datalayer.eachFeature((feature) => { + names.forEach((name) => { + let value = feature.properties[name] + const type = defined[name]['type'] + switch (type) { + case 'date': + case 'datetime': + case 'number': + value = type === 'number' ? parseFloat(value) : new Date(value) + if (!isNaN(value)) { + if (isNaN(properties[name].min) || properties[name].min > value) { + properties[name].min = value + } + if (isNaN(properties[name].max) || properties[name].max < value) { + properties[name].max = value + } + } + break + default: + value = String(value || '') || L._('') + if (!properties[name].choices.includes(value)) { + properties[name].choices.push(value) + } + } + }) + }) + }) + return properties + } + + open() { + const container = L.DomUtil.create('div', 'umap-facet-search') + const title = L.DomUtil.add( + 'h3', + 'umap-filter-title', + container, + L._('Facet search') + ) + const defined = this.getDefined() + const names = Object.keys(defined) + const facetProperties = this.compute(names, defined) + + const filterFeatures = function () { + let found = false + this.map.eachBrowsableDataLayer((datalayer) => { + datalayer.resetLayer(true) + if (datalayer.hasDataVisible()) found = true + }) + // TODO: display a results counter in the panel instead. + if (!found) { + this.map.ui.alert({ + content: L._('No results for these facets'), + level: 'info', + }) + } + } + + const fields = names.map((name) => { + let criteria = facetProperties[name] + let handler = 'FacetSearchChoices' + switch (criteria['type']) { + case 'number': + handler = 'FacetSearchNumber' + break + case 'date': + handler = 'FacetSearchDate' + break + case 'datetime': + handler = 'FacetSearchDateTime' + break + } + let label = defined[name]['label'] + return [ + `selected.${name}`, + { + criteria: criteria, + handler: handler, + label: label, + }, + ] + }) + + const builder = new U.FormBuilder(this, fields, { + makeDirty: false, + callback: filterFeatures, + callbackContext: this, + }) + container.appendChild(builder.build()) + + this.map.panel.open({ content: container }) + } + + getDefined() { + const defaultType = 'checkbox' + const allowedTypes = [defaultType, 'radio', 'number', 'date', 'datetime'] + return (this.map.options.facetKey || '').split(',').reduce((acc, curr) => { + let [name, label, type] = curr.split('|') + type = allowedTypes.includes(type) ? type : defaultType + acc[name] = { label: label || name, type: type } + return acc + }, {}) + } +} diff --git a/umap/static/umap/js/modules/global.js b/umap/static/umap/js/modules/global.js index 7d07ba2f..16742508 100644 --- a/umap/static/umap/js/modules/global.js +++ b/umap/static/umap/js/modules/global.js @@ -1,5 +1,6 @@ import URLs from './urls.js' import Browser from './browser.js' +import Facets from './facets.js' import { Panel, EditPanel, FullPanel } from './panel.js' import * as Utils from './utils.js' import { SCHEMA } from './schema.js' @@ -17,6 +18,7 @@ window.U = { HTTPError, NOKError, Browser, + Facets, Panel, EditPanel, FullPanel, diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 884633dc..384e7e31 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -661,117 +661,7 @@ const ControlsMixin = { 'tilelayers', ], _openFacet: function () { - const container = L.DomUtil.create('div', 'umap-facet-search'), - title = L.DomUtil.add('h3', 'umap-filter-title', container, L._('Facet search')), - facetKeys = this.getFacetKeys(), - keys = Object.keys(facetKeys) - - const facetCriteria = {} - - keys.forEach((key) => { - const type = facetKeys[key]['type'] - if (['date', 'datetime', 'number'].includes(type)) { - if (!facetCriteria[key]) - facetCriteria[key] = { - type: facetKeys[key]['type'], - min: undefined, - max: undefined, - } - if (!this.facets[key]) - this.facets[key] = { - type: facetKeys[key]['type'], - min: undefined, - max: undefined, - } - } else { - if (!facetCriteria[key]) - facetCriteria[key] = { - type: facetKeys[key]['type'], - choices: [], - } - if (!this.facets[key]) - this.facets[key] = { - type: facetKeys[key]['type'], - choices: [], - } - } - }) - - this.eachBrowsableDataLayer((datalayer) => { - datalayer.eachFeature((feature) => { - keys.forEach((key) => { - let value = feature.properties[key] - const type = facetKeys[key]['type'] - if (['date', 'datetime', 'number'].includes(type)) { - value = value != null ? value : undefined - if (['date', 'datetime'].includes(type)) value = new Date(value) - if (['number'].includes(type)) value = parseFloat(value) - if ( - !isNaN(value) && - (isNaN(facetCriteria[key]['min']) || facetCriteria[key]['min'] > value) - ) { - facetCriteria[key]['min'] = value - } - if ( - !isNaN(value) && - (isNaN(facetCriteria[key]['max']) || facetCriteria[key]['max'] < value) - ) { - facetCriteria[key]['max'] = value - } - } else { - value = String(value) - value = value.length ? value : L._('empty string') - if (!!value && !facetCriteria[key]['choices'].includes(value)) { - facetCriteria[key]['choices'].push(value) - } - } - }) - }) - }) - - const filterFeatures = function () { - let found = false - this.eachBrowsableDataLayer((datalayer) => { - datalayer.resetLayer(true) - if (datalayer.hasDataVisible()) found = true - }) - // TODO: display a results counter in the panel instead. - if (!found) { - this.ui.alert({ content: L._('No results for these facets'), level: 'info' }) - } - } - const fields = keys.map((key) => { - let criteria = facetCriteria[key] - let handler = 'FacetSearchChoices' - switch (criteria['type']) { - case 'number': - handler = 'FacetSearchNumber' - break - case 'date': - handler = 'FacetSearchDate' - break - case 'datetime': - handler = 'FacetSearchDateTime' - break - } - let label = facetKeys[key]['label'] - return [ - `facets.${key}`, - { - criteria: criteria, - handler: handler, - label: label, - }, - ] - }) - const builder = new U.FormBuilder(this, fields, { - makeDirty: false, - callback: filterFeatures, - callbackContext: this, - }) - container.appendChild(builder.build()) - - this.panel.open({ content: container }) + this.facets.open() }, displayCaption: function () { diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 06313859..aae48995 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -73,7 +73,7 @@ L.DomUtil.add = (tagName, className, container, content) => { if (content.nodeType && content.nodeType === 1) { el.appendChild(content) } else { - el.innerHTML = content + el.textContent = content } } return el diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index b6fce1b7..25f2ea39 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -493,7 +493,7 @@ U.FeatureMixin = { }, matchFacets: function () { - const facets = this.map.facets + const facets = this.map.facets.selected for (const [property, criteria] of Object.entries(facets)) { let value = this.properties[property] const type = criteria["type"] @@ -515,8 +515,7 @@ U.FeatureMixin = { if (!isNaN(max) && !isNaN(value) && max < value) return false } else { const choices = criteria["choices"] - value = String(value) - value = (value.length ? value : L._("empty string")) + value = String(value || '') || L._("") if (choices?.length && (!value || !choices.includes(value))) return false } } diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 7f4bdc90..e61741ff 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -116,7 +116,6 @@ U.Map = L.Map.extend({ this.datalayers_index = [] this.dirty_datalayers = [] this.features_index = {} - this.facets = {} // Needed for actions labels this.help = new U.Help(this) @@ -377,6 +376,7 @@ U.Map = L.Map.extend({ if (this.options.scrollWheelZoom) this.scrollWheelZoom.enable() else this.scrollWheelZoom.disable() this.browser = new U.Browser(this) + this.facets = new U.Facets(this) this.importer = new U.Importer(this) this.drop = new U.DropControl(this) this.share = new U.Share(this) @@ -1846,20 +1846,6 @@ U.Map = L.Map.extend({ return (this.options.filterKey || this.options.sortKey || 'name').split(',') }, - getFacetKeys: function () { - const defaultType = 'checkbox' - const allowedTypes = [defaultType, 'radio', 'number', 'date', 'datetime'] - return (this.options.facetKey || '').split(',').reduce((acc, curr) => { - let [key, label, type] = curr.split('|') - type = allowedTypes.includes(type) ? type : defaultType - acc[key] = { - label: label || key, - type: type, - } - return acc - }, {}) - }, - getLayersBounds: function () { const bounds = new L.latLngBounds() this.eachBrowsableDataLayer((d) => { From 9c326d09e173e77f7afcbf4de2d87080dc1aeed0 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 19 Apr 2024 12:55:35 +0200 Subject: [PATCH 15/24] chore: remove the need for makeDirty in U.FormBuilder --- umap/static/umap/js/modules/browser.js | 3 +-- umap/static/umap/js/modules/facets.js | 3 +-- umap/static/umap/js/umap.forms.js | 6 ++---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/umap/static/umap/js/modules/browser.js b/umap/static/umap/js/modules/browser.js index ac0293da..046324bb 100644 --- a/umap/static/umap/js/modules/browser.js +++ b/umap/static/umap/js/modules/browser.js @@ -142,8 +142,7 @@ export default class Browser { ['options.filter', { handler: 'Input', placeholder: translate('Filter') }], ['options.inBbox', { handler: 'Switch', label: translate('Current map view') }], ] - const builder = new U.FormBuilder(this, fields, { - makeDirty: false, + const builder = new L.FormBuilder(this, fields, { callback: () => this.onFormChange(), }) formContainer.appendChild(builder.build()) diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js index d141e1de..f009ff06 100644 --- a/umap/static/umap/js/modules/facets.js +++ b/umap/static/umap/js/modules/facets.js @@ -103,8 +103,7 @@ export default class Facets { ] }) - const builder = new U.FormBuilder(this, fields, { - makeDirty: false, + const builder = new L.FormBuilder(this, fields, { callback: filterFeatures, callbackContext: this, }) diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 42ab4c1a..8f9619e2 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -1117,10 +1117,8 @@ U.FormBuilder = L.FormBuilder.extend({ setter: function (field, value) { L.FormBuilder.prototype.setter.call(this, field, value) - if (this.options.makeDirty !== false) { - this.obj.isDirty = true - if ('render' in this.obj) this.obj.render([field], this) - } + this.obj.isDirty = true + if ('render' in this.obj) this.obj.render([field], this) }, finish: function () { From 91f0f29d5ea228efc5b4f6bfa92ab29ea09e251f Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Wed, 17 Apr 2024 18:26:35 +0200 Subject: [PATCH 16/24] chore: refactor FeatureMixin.matchFacets --- umap/static/umap/js/umap.features.js | 43 ++++++++++++---------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 25f2ea39..275bada8 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -493,30 +493,25 @@ U.FeatureMixin = { }, matchFacets: function () { - const facets = this.map.facets.selected - for (const [property, criteria] of Object.entries(facets)) { - let value = this.properties[property] - const type = criteria["type"] - if (["date", "datetime", "number"].includes(type)) { - let min = criteria["min"] - let max = criteria["max"] - value = (value != null ? value : undefined) - if (["date", "datetime"].includes(type)) { - min = new Date(min) - max = new Date(max) - value = new Date(value) - } - if (["number"].includes(type)) { - min = parseFloat(min) - max = parseFloat(max) - value = parseFloat(value) - } - if (!isNaN(min) && !isNaN(value) && min > value) return false - if (!isNaN(max) && !isNaN(value) && max < value) return false - } else { - const choices = criteria["choices"] - value = String(value || '') || L._("") - if (choices?.length && (!value || !choices.includes(value))) return false + const selected = this.map.facets.selected + for (let [name, { type, min, max, choices }] of Object.entries(selected)) { + let value = this.properties[name] + switch (type) { + case 'date': + case 'datetime': + case 'number': + const caster = type === 'number' ? parseFloat : (v) => new Date(v) + value = value != null ? value : undefined + min = caster(min) + max = caster(max) + value = caster(value) + if (!isNaN(min) && !isNaN(value) && min > value) return false + if (!isNaN(max) && !isNaN(value) && max < value) return false + break + default: + value = String(value || '') || L._('') + if (choices?.length && (!value || !choices.includes(value))) return false + break } } return true From 0b1693a1a248f7e58c22ed487a9129a5d4483bf3 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 18 Apr 2024 15:27:17 +0200 Subject: [PATCH 17/24] chore: add some tests for facet search --- umap/static/umap/js/umap.features.js | 11 +- umap/tests/integration/test_facets_browser.py | 121 ++++++++++++++++-- 2 files changed, 114 insertions(+), 18 deletions(-) diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 275bada8..9e68deeb 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -496,15 +496,18 @@ U.FeatureMixin = { const selected = this.map.facets.selected for (let [name, { type, min, max, choices }] of Object.entries(selected)) { let value = this.properties[name] + let parser switch (type) { case 'date': case 'datetime': case 'number': - const caster = type === 'number' ? parseFloat : (v) => new Date(v) + if (type === 'number') parser = parseFloat + else if (type == 'datetime') parser = (v) => new Date(v) + else parser = (v) => new Date(new Date(v).toDateString()) // Without time value = value != null ? value : undefined - min = caster(min) - max = caster(max) - value = caster(value) + min = parser(min) + max = parser(max) + value = parser(value) if (!isNaN(min) && !isNaN(value) && min > value) return false if (!isNaN(max) && !isNaN(value) && max < value) return false break diff --git a/umap/tests/integration/test_facets_browser.py b/umap/tests/integration/test_facets_browser.py index 68e64ee3..19faf78d 100644 --- a/umap/tests/integration/test_facets_browser.py +++ b/umap/tests/integration/test_facets_browser.py @@ -1,3 +1,5 @@ +import copy + import pytest from playwright.sync_api import expect @@ -11,12 +13,24 @@ DATALAYER_DATA1 = { "features": [ { "type": "Feature", - "properties": {"mytype": "even", "name": "Point 2"}, + "properties": { + "mytype": "even", + "name": "Point 2", + "mynumber": 10, + # Same day/month value to work around Javascript/browser + # crazyness date format + "mydate": "2024/04/04 12:19:17", + }, "geometry": {"type": "Point", "coordinates": [0.065918, 48.385442]}, }, { "type": "Feature", - "properties": {"mytype": "odd", "name": "Point 1"}, + "properties": { + "mytype": "odd", + "name": "Point 1", + "mynumber": 12, + "mydate": "2024/03/03 12:20:20", + }, "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]}, }, ], @@ -31,12 +45,22 @@ DATALAYER_DATA2 = { "features": [ { "type": "Feature", - "properties": {"mytype": "even", "name": "Point 4"}, + "properties": { + "mytype": "even", + "name": "Point 4", + "mynumber": 10, + "mydate": "2024/08/08 13:14:15", + }, "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, }, { "type": "Feature", - "properties": {"mytype": "odd", "name": "Point 3"}, + "properties": { + "mytype": "odd", + "name": "Point 3", + "mynumber": 14, + "mydate": "2024/04/04 10:19:17", + }, "geometry": {"type": "Point", "coordinates": [4.372559, 47.945786]}, }, ], @@ -70,23 +94,19 @@ DATALAYER_DATA3 = { } -@pytest.fixture -def bootstrap(map, live_server): +def test_simple_facet_search(live_server, page, map): map.settings["properties"]["onLoadPanel"] = "facet" - map.settings["properties"]["facetKey"] = "mytype|My type" + map.settings["properties"]["facetKey"] = "mytype|My type,mynumber|My Number|number" map.settings["properties"]["showLabel"] = True map.save() DataLayerFactory(map=map, data=DATALAYER_DATA1) DataLayerFactory(map=map, data=DATALAYER_DATA2) DataLayerFactory(map=map, data=DATALAYER_DATA3) - - -def test_simple_facet_search(live_server, page, bootstrap, map): - page.goto(f"{live_server.url}{map.get_absolute_url()}") + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") panel = page.locator(".umap-facet-search") # From a non browsable datalayer, should not be impacted paths = page.locator(".leaflet-overlay-pane path") - expect(paths).to_be_visible + expect(paths).to_be_visible() expect(panel).to_be_visible() # Facet name expect(page.get_by_text("My type")).to_be_visible() @@ -95,7 +115,7 @@ def test_simple_facet_search(live_server, page, bootstrap, map): odd = page.get_by_text("odd") expect(oven).to_be_visible() expect(odd).to_be_visible() - expect(paths).to_be_visible + expect(paths).to_be_visible() markers = page.locator(".leaflet-marker-icon") expect(markers).to_have_count(4) # Tooltips @@ -114,4 +134,77 @@ def test_simple_facet_search(live_server, page, bootstrap, map): # Now let's filter odd.click() expect(markers).to_have_count(4) - expect(paths).to_be_visible + expect(paths).to_be_visible() + + # Let's filter using the number facet + expect(page.get_by_text("My Number")).to_be_visible() + expect(page.get_by_label("Min")).to_have_value("10") + expect(page.get_by_label("Max")).to_have_value("14") + page.get_by_label("Min").fill("11") + page.keyboard.press("Tab") # Move out of the input, so the "change" event is sent + expect(markers).to_have_count(2) + expect(paths).to_be_visible() + page.get_by_label("Max").fill("13") + page.keyboard.press("Tab") + expect(markers).to_have_count(1) + + # Now let's combine + page.get_by_label("Min").fill("10") + page.keyboard.press("Tab") + expect(markers).to_have_count(3) + odd.click() + expect(markers).to_have_count(1) + expect(paths).to_be_visible() + + +def test_date_facet_search(live_server, page, map): + map.settings["properties"]["onLoadPanel"] = "facet" + map.settings["properties"]["facetKey"] = "mydate|Date filter|date" + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}") + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(4) + expect(page.get_by_text("Date Filter")).to_be_visible() + expect(page.get_by_label("From")).to_have_value("2024-03-03") + expect(page.get_by_label("Until")).to_have_value("2024-08-08") + # Change the month (crazy US date format in Chromium ran by Playwright in my OS) + page.get_by_label("From").press("ArrowUp") + expect(markers).to_have_count(3) + page.get_by_label("Until").press("ArrowDown") + expect(markers).to_have_count(2) + + +def test_choice_with_empty_value(live_server, page, map): + map.settings["properties"]["onLoadPanel"] = "facet" + map.settings["properties"]["facetKey"] = "mytype|My type" + map.save() + data = copy.deepcopy(DATALAYER_DATA1) + data["features"][0]["properties"]["mytype"] = "" + del data["features"][1]["properties"]["mytype"] + DataLayerFactory(map=map, data=data) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(page.get_by_text("")).to_be_visible() + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(4) + page.get_by_text("").click() + expect(markers).to_have_count(2) + + +def test_number_with_zero_value(live_server, page, map): + map.settings["properties"]["onLoadPanel"] = "facet" + map.settings["properties"]["facetKey"] = "mynumber|Filter|number" + map.save() + data = copy.deepcopy(DATALAYER_DATA1) + data["features"][0]["properties"]["mynumber"] = 0 + DataLayerFactory(map=map, data=data) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}") + expect(page.get_by_label("Min")).to_have_value("0") + expect(page.get_by_label("Max")).to_have_value("14") + page.get_by_label("Min").fill("1") + page.keyboard.press("Tab") # Move out of the input, so the "change" event is sent + markers = page.locator(".leaflet-marker-icon") + expect(markers).to_have_count(3) From 68a22a9177cab7f15de44a34fe5eb756d8daaa4e Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 18 Apr 2024 15:56:06 +0200 Subject: [PATCH 18/24] chore: redraw facets panel if open when changing facetKey --- umap/static/umap/js/modules/facets.js | 8 ++++++++ umap/static/umap/js/umap.js | 10 +++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js index f009ff06..c0b57086 100644 --- a/umap/static/umap/js/modules/facets.js +++ b/umap/static/umap/js/modules/facets.js @@ -51,6 +51,14 @@ export default class Facets { return properties } + redraw() { + if (this.isOpen()) this.open() + } + + isOpen() { + return !!document.querySelector('.umap-facet-search') + } + open() { const container = L.DomUtil.create('div', 'umap-facet-search') const title = L.DomUtil.add( diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index e61741ff..0f4f42f2 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -106,10 +106,13 @@ U.Map = L.Map.extend({ this.options.slideshow && this.options.slideshow.delay && this.options.slideshow.active === undefined - ) + ) { this.options.slideshow.active = true - if (this.options.advancedFilterKey) + } + if (this.options.advancedFilterKey) { this.options.facetKey = this.options.advancedFilterKey + delete this.options.advancedFilterKey + } // Global storage for retrieving datalayers and features this.datalayers = {} @@ -251,6 +254,7 @@ U.Map = L.Map.extend({ this.initCaptionBar() this.renderEditToolbar() this.renderControls() + this.facets.redraw() break case 'data': this.redrawVisibleDataLayers() @@ -1235,7 +1239,7 @@ U.Map = L.Map.extend({ [ 'options.facetKey', { - handler: 'Input', + handler: 'BlurInput', helpEntries: 'facetKey', placeholder: L._('Example: key1,key2|Label 2,key3|Label 3|checkbox'), label: L._('Facet keys'), From ab85e945beeb71962ac88782957c0c8c691e4126 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 18 Apr 2024 16:09:57 +0200 Subject: [PATCH 19/24] chore: remove unwanted span in translated string --- umap/static/umap/js/umap.controls.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 384e7e31..a77d7ae6 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -817,7 +817,7 @@ const ControlsMixin = { L.DomUtil.createLink( 'umap-user', rightContainer, - L._(`My Dashboard ({username})`, { + L._(`My Dashboard ({username})`, { username: this.options.user.name, }), this.options.user.url From b0c6b42c1798c18f9ba32b7eca027c128ec28057 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 18 Apr 2024 16:13:17 +0200 Subject: [PATCH 20/24] chore: fix typo in css --- umap/static/umap/base.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index dfeb9b02..c47bd062 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -350,7 +350,7 @@ fieldset legend { font-size: 1.1rem; padding: 0 5px; } -} + /* Switch */ input.switch:empty { display: none; From eb0d17154c19c179a7e4be09bd389f5b191b0ed3 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 18 Apr 2024 16:22:00 +0200 Subject: [PATCH 21/24] chore: add facets.getParser method --- umap/static/umap/js/modules/facets.js | 21 +++++++++++++++++++-- umap/static/umap/js/umap.features.js | 11 ++++------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js index c0b57086..74ae3d2c 100644 --- a/umap/static/umap/js/modules/facets.js +++ b/umap/static/umap/js/modules/facets.js @@ -25,11 +25,12 @@ export default class Facets { names.forEach((name) => { let value = feature.properties[name] const type = defined[name]['type'] + const parser = this.getParser(type) + value = parser(value) switch (type) { case 'date': case 'datetime': case 'number': - value = type === 'number' ? parseFloat(value) : new Date(value) if (!isNaN(value)) { if (isNaN(properties[name].min) || properties[name].min > value) { properties[name].min = value @@ -40,7 +41,7 @@ export default class Facets { } break default: - value = String(value || '') || L._('') + value = value || L._('') if (!properties[name].choices.includes(value)) { properties[name].choices.push(value) } @@ -130,4 +131,20 @@ export default class Facets { return acc }, {}) } + + getParser(type) { + switch(type) { + case 'number': + return parseFloat + case 'datetime': + return (v) => new Date(v) + case 'date': + return (v) => new Date(new Date(v).toDateString()) // Without time + default: + return (v) => String(v || '') + + } + } + + } diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 9e68deeb..f8411ab3 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -496,15 +496,12 @@ U.FeatureMixin = { const selected = this.map.facets.selected for (let [name, { type, min, max, choices }] of Object.entries(selected)) { let value = this.properties[name] - let parser + let parser = this.map.facets.getParser(type) + value = parser(value) switch (type) { case 'date': case 'datetime': case 'number': - if (type === 'number') parser = parseFloat - else if (type == 'datetime') parser = (v) => new Date(v) - else parser = (v) => new Date(new Date(v).toDateString()) // Without time - value = value != null ? value : undefined min = parser(min) max = parser(max) value = parser(value) @@ -512,8 +509,8 @@ U.FeatureMixin = { if (!isNaN(max) && !isNaN(value) && max < value) return false break default: - value = String(value || '') || L._('') - if (choices?.length && (!value || !choices.includes(value))) return false + value = value || L._('') + if (choices?.length && !choices.includes(value)) return false break } } From 37e0582b306745be9622a858d0697d7146a3a300 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 18 Apr 2024 19:03:03 +0200 Subject: [PATCH 22/24] chore: try to parse naive dates as UTC --- umap/static/umap/js/modules/facets.js | 8 +++----- umap/static/umap/js/modules/utils.js | 10 ++++++++-- umap/static/umap/js/umap.features.js | 1 - umap/static/umap/unittests/utils.js | 17 +++++++++++++++++ umap/tests/integration/test_facets_browser.py | 2 +- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js index 74ae3d2c..def0541b 100644 --- a/umap/static/umap/js/modules/facets.js +++ b/umap/static/umap/js/modules/facets.js @@ -1,5 +1,6 @@ import { DomUtil, DomEvent, stamp } from '../../vendors/leaflet/leaflet-src.esm.js' import { translate } from './i18n.js' +import * as Utils from './utils.js' export default class Facets { constructor(map) { @@ -133,18 +134,15 @@ export default class Facets { } getParser(type) { - switch(type) { + switch (type) { case 'number': return parseFloat case 'datetime': return (v) => new Date(v) case 'date': - return (v) => new Date(new Date(v).toDateString()) // Without time + return Utils.parseNaiveDate default: return (v) => String(v || '') - } } - - } diff --git a/umap/static/umap/js/modules/utils.js b/umap/static/umap/js/modules/utils.js index 125945ee..5b18286e 100644 --- a/umap/static/umap/js/modules/utils.js +++ b/umap/static/umap/js/modules/utils.js @@ -27,9 +27,9 @@ export function checkId(string) { /** * Compute the impacts for a given list of fields. - * + * * Return an array of unique impacts. - * + * * @param {fields} list[fields] * @returns Array[string] */ @@ -356,3 +356,9 @@ export function template(str, data) { return value }) } + +export function parseNaiveDate(value) { + const naive = new Date(value) + // Let's pretend naive date are UTC, and remove time… + return new Date(Date.UTC(naive.getFullYear(), naive.getMonth(), naive.getDate())) +} diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index f8411ab3..d0749a81 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -504,7 +504,6 @@ U.FeatureMixin = { case 'number': min = parser(min) max = parser(max) - value = parser(value) if (!isNaN(min) && !isNaN(value) && min > value) return false if (!isNaN(max) && !isNaN(value) && max < value) return false break diff --git a/umap/static/umap/unittests/utils.js b/umap/static/umap/unittests/utils.js index 68148c56..4624b273 100644 --- a/umap/static/umap/unittests/utils.js +++ b/umap/static/umap/unittests/utils.js @@ -590,4 +590,21 @@ describe('Utils', function () { assert.deepEqual(getImpactsFromSchema(['foo', 'bar', 'baz'], schema), ['A', 'B']) }) }) + describe('parseNaiveDate', () => { + it('should parse a date', () => { + assert.equal(Utils.parseNaiveDate("2024/03/04").toISOString(), "2024-03-04T00:00:00.000Z") + }) + it('should parse a datetime', () => { + assert.equal(Utils.parseNaiveDate("2024/03/04 12:13:14").toISOString(), "2024-03-04T00:00:00.000Z") + }) + it('should parse an iso datetime', () => { + assert.equal(Utils.parseNaiveDate("2024-03-04T00:00:00.000Z").toISOString(), "2024-03-04T00:00:00.000Z") + }) + it('should parse a GMT time', () => { + assert.equal(Utils.parseNaiveDate("04 Mar 2024 00:12:00 GMT").toISOString(), "2024-03-04T00:00:00.000Z") + }) + it('should parse a GMT time with explicit timezone', () => { + assert.equal(Utils.parseNaiveDate("Thu, 04 Mar 2024 00:00:00 GMT+0300").toISOString(), "2024-03-03T00:00:00.000Z") + }) + }) }) diff --git a/umap/tests/integration/test_facets_browser.py b/umap/tests/integration/test_facets_browser.py index 19faf78d..37bc1eba 100644 --- a/umap/tests/integration/test_facets_browser.py +++ b/umap/tests/integration/test_facets_browser.py @@ -59,7 +59,7 @@ DATALAYER_DATA2 = { "mytype": "odd", "name": "Point 3", "mynumber": 14, - "mydate": "2024/04/04 10:19:17", + "mydate": "2024-04-04T10:19:17.000Z", }, "geometry": {"type": "Point", "coordinates": [4.372559, 47.945786]}, }, From a1d78437d5232b87593892dcff2567205824878f Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 19 Apr 2024 13:19:20 +0200 Subject: [PATCH 23/24] chore: use translate instead of L._ in facets module --- umap/static/umap/js/modules/facets.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/umap/static/umap/js/modules/facets.js b/umap/static/umap/js/modules/facets.js index def0541b..2d0c6e62 100644 --- a/umap/static/umap/js/modules/facets.js +++ b/umap/static/umap/js/modules/facets.js @@ -42,7 +42,7 @@ export default class Facets { } break default: - value = value || L._('') + value = value || translate('') if (!properties[name].choices.includes(value)) { properties[name].choices.push(value) } @@ -67,7 +67,7 @@ export default class Facets { 'h3', 'umap-filter-title', container, - L._('Facet search') + translate('Facet search') ) const defined = this.getDefined() const names = Object.keys(defined) @@ -82,7 +82,7 @@ export default class Facets { // TODO: display a results counter in the panel instead. if (!found) { this.map.ui.alert({ - content: L._('No results for these facets'), + content: translate('No results for these facets'), level: 'info', }) } From c5507fbafe2e50977689864b9c73f3c1113ef11e Mon Sep 17 00:00:00 2001 From: David Larlet Date: Fri, 19 Apr 2024 10:22:12 -0400 Subject: [PATCH 24/24] fix: tests with iso dates and explicit days --- umap/tests/integration/test_facets_browser.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/umap/tests/integration/test_facets_browser.py b/umap/tests/integration/test_facets_browser.py index 37bc1eba..f72d3124 100644 --- a/umap/tests/integration/test_facets_browser.py +++ b/umap/tests/integration/test_facets_browser.py @@ -17,9 +17,7 @@ DATALAYER_DATA1 = { "mytype": "even", "name": "Point 2", "mynumber": 10, - # Same day/month value to work around Javascript/browser - # crazyness date format - "mydate": "2024/04/04 12:19:17", + "mydate": "2024/04/14 12:19:17", }, "geometry": {"type": "Point", "coordinates": [0.065918, 48.385442]}, }, @@ -29,7 +27,7 @@ DATALAYER_DATA1 = { "mytype": "odd", "name": "Point 1", "mynumber": 12, - "mydate": "2024/03/03 12:20:20", + "mydate": "2024/03/13 12:20:20", }, "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]}, }, @@ -49,7 +47,7 @@ DATALAYER_DATA2 = { "mytype": "even", "name": "Point 4", "mynumber": 10, - "mydate": "2024/08/08 13:14:15", + "mydate": "2024/08/18 13:14:15", }, "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, }, @@ -59,7 +57,7 @@ DATALAYER_DATA2 = { "mytype": "odd", "name": "Point 3", "mynumber": 14, - "mydate": "2024-04-04T10:19:17.000Z", + "mydate": "2024-04-14T10:19:17.000Z", }, "geometry": {"type": "Point", "coordinates": [4.372559, 47.945786]}, }, @@ -167,12 +165,11 @@ def test_date_facet_search(live_server, page, map): markers = page.locator(".leaflet-marker-icon") expect(markers).to_have_count(4) expect(page.get_by_text("Date Filter")).to_be_visible() - expect(page.get_by_label("From")).to_have_value("2024-03-03") - expect(page.get_by_label("Until")).to_have_value("2024-08-08") - # Change the month (crazy US date format in Chromium ran by Playwright in my OS) - page.get_by_label("From").press("ArrowUp") + expect(page.get_by_label("From")).to_have_value("2024-03-13") + expect(page.get_by_label("Until")).to_have_value("2024-08-18") + page.get_by_label("From").fill("2024-03-14") expect(markers).to_have_count(3) - page.get_by_label("Until").press("ArrowDown") + page.get_by_label("Until").fill("2024-08-17") expect(markers).to_have_count(2)