Merge pull request #1763 from umap-project/flammermann-facet-date

Date and number support for facets
This commit is contained in:
David Larlet 2024-04-19 10:58:02 -04:00 committed by GitHub
commit d8c2e14b42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 429 additions and 112 deletions

View file

@ -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,6 +263,9 @@ input[type="checkbox"] + label {
display: inline;
padding: 0 14px;
}
label input[type="radio"] {
margin-right: 10px;
}
select + .error,
input + .error {
display: block;
@ -343,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;

View file

@ -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())

View file

@ -0,0 +1,148 @@
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) {
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']
const parser = this.getParser(type)
value = parser(value)
switch (type) {
case 'date':
case 'datetime':
case 'number':
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 = value || translate('<empty value>')
if (!properties[name].choices.includes(value)) {
properties[name].choices.push(value)
}
}
})
})
})
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(
'h3',
'umap-filter-title',
container,
translate('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: translate('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 L.FormBuilder(this, fields, {
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
}, {})
}
getParser(type) {
switch (type) {
case 'number':
return parseFloat
case 'datetime':
return (v) => new Date(v)
case 'date':
return Utils.parseNaiveDate
default:
return (v) => String(v || '')
}
}
}

View file

@ -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,

View file

@ -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()))
}

View file

@ -661,55 +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')),
keys = Object.keys(this.getFacetKeys())
const knownValues = {}
keys.forEach((key) => {
knownValues[key] = []
if (!this.facets[key]) this.facets[key] = []
})
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)
}
})
})
})
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((current) => [
`facets.${current}`,
{
handler: 'FacetSearch',
choices: knownValues[current],
label: this.getFacetKeys()[current],
},
])
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 () {
@ -865,7 +817,7 @@ const ControlsMixin = {
L.DomUtil.createLink(
'umap-user',
rightContainer,
L._(`My Dashboard <span>({username})</span>`, {
L._(`My Dashboard ({username})`, {
username: this.options.user.name,
}),
this.options.user.url

View file

@ -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
@ -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 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.'

View file

@ -493,11 +493,24 @@ U.FeatureMixin = {
},
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
const selected = this.map.facets.selected
for (let [name, { type, min, max, choices }] of Object.entries(selected)) {
let value = this.properties[name]
let parser = this.map.facets.getParser(type)
value = parser(value)
switch (type) {
case 'date':
case 'datetime':
case 'number':
min = parser(min)
max = parser(max)
if (!isNaN(min) && !isNaN(value) && min > value) return false
if (!isNaN(max) && !isNaN(value) && max < value) return false
break
default:
value = value || L._('<empty value>')
if (choices?.length && !choices.includes(value)) return false
break
}
}
return true

View file

@ -744,34 +744,122 @@ L.FormBuilder.Switch = L.FormBuilder.CheckBox.extend({
},
})
L.FormBuilder.FacetSearch = L.FormBuilder.Element.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)
const choices = this.options.choices
this.type = this.options.criteria['type']
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)
input.type = 'checkbox'
input.id = `checkbox_${this.name}_${value}`
input.checked = this.get().includes(value)
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.type
input.name = `${this.type}_${this.name}`
input.checked = this.get()['choices'].includes(value)
input.dataset.value = value
label.htmlFor = `checkbox_${this.name}_${value}`
label.innerHTML = value
L.DomEvent.on(input, 'change', (e) => this.sync())
},
toJS: function () {
return [...this.ul.querySelectorAll('input:checked')].map((i) => i.dataset.value)
return {
type: this.type,
choices: [...this.ul.querySelectorAll('input:checked')].map(
(i) => i.dataset.value
),
}
},
})
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('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 [minLabel, maxLabel] = this.getLabels()
this.minLabel = L.DomUtil.create('label', '', this.container)
this.minLabel.innerHTML = minLabel
this.minInput = L.DomUtil.create('input', '', this.minLabel)
this.minInput.type = this.inputType
this.minInput.step = 'any'
if (min != null) {
this.minInput.valueAsNumber = this.castValue(min)
this.minInput.dataset.value = min
}
this.maxLabel = L.DomUtil.create('label', '', this.container)
this.maxLabel.innerHTML = maxLabel
this.maxInput = L.DomUtil.create('input', '', this.maxLabel)
this.maxInput.type = this.inputType
this.maxInput.step = 'any'
if (max != null) {
this.maxInput.valueAsNumber = this.castValue(max)
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.element('legend', {textContent: this.options.label})
},
toJS: function () {
return {
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'
},
})
@ -1029,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)
}
},
finish: function () {

View file

@ -106,17 +106,19 @@ 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 = {}
this.datalayers_index = []
this.dirty_datalayers = []
this.features_index = {}
this.facets = {}
// Needed for actions labels
this.help = new U.Help(this)
@ -218,6 +220,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')) {
@ -251,6 +254,7 @@ U.Map = L.Map.extend({
this.initCaptionBar()
this.renderEditToolbar()
this.renderControls()
this.facets.redraw()
break
case 'data':
this.redrawVisibleDataLayers()
@ -376,6 +380,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)
@ -1234,9 +1239,9 @@ U.Map = L.Map.extend({
[
'options.facetKey',
{
handler: 'Input',
handler: 'BlurInput',
helpEntries: 'facetKey',
placeholder: L._('Example: key1,key2,key3'),
placeholder: L._('Example: key1,key2|Label 2,key3|Label 3|checkbox'),
label: L._('Facet keys'),
},
],
@ -1845,14 +1850,6 @@ U.Map = L.Map.extend({
return (this.options.filterKey || this.options.sortKey || 'name').split(',')
},
getFacetKeys: function () {
return (this.options.facetKey || '').split(',').reduce((acc, curr) => {
const els = curr.split('|')
acc[els[0]] = els[1] || els[0]
return acc
}, {})
},
getLayersBounds: function () {
const bounds = new L.latLngBounds()
this.eachBrowsableDataLayer((d) => {

View file

@ -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;
}

View file

@ -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")
})
})
})

View file

@ -1,3 +1,5 @@
import copy
import pytest
from playwright.sync_api import expect
@ -11,12 +13,22 @@ DATALAYER_DATA1 = {
"features": [
{
"type": "Feature",
"properties": {"mytype": "even", "name": "Point 2"},
"properties": {
"mytype": "even",
"name": "Point 2",
"mynumber": 10,
"mydate": "2024/04/14 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/13 12:20:20",
},
"geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]},
},
],
@ -31,12 +43,22 @@ DATALAYER_DATA2 = {
"features": [
{
"type": "Feature",
"properties": {"mytype": "even", "name": "Point 4"},
"properties": {
"mytype": "even",
"name": "Point 4",
"mynumber": 10,
"mydate": "2024/08/18 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-14T10:19:17.000Z",
},
"geometry": {"type": "Point", "coordinates": [4.372559, 47.945786]},
},
],
@ -70,23 +92,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 +113,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 +132,76 @@ 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-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").fill("2024-08-17")
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("<empty value>")).to_be_visible()
markers = page.locator(".leaflet-marker-icon")
expect(markers).to_have_count(4)
page.get_by_text("<empty value>").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)