Merge pull request #1243 from umap-project/filters-refacto

Refactor "advanced filters" (and rename to facets)
This commit is contained in:
Yohan Boniface 2023-08-15 16:22:55 +02:00 committed by GitHub
commit 0f2198b4ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 165 additions and 173 deletions

View file

@ -481,7 +481,7 @@ i.info {
.umap-datalayer-container,
.umap-layer-properties-container,
.umap-browse-data,
.umap-filter-data,
.umap-facet-search,
.umap-browse-datalayers {
padding: 0 10px;
}

View file

@ -753,11 +753,7 @@ L.U.Map.include({
const build = () => {
ul.innerHTML = ''
datalayer.eachFeature((feature) => {
if (
(filterValue && !feature.matchFilter(filterValue, filterKeys)) ||
feature.properties.isVisible === false
)
return
if (filterValue && !feature.matchFilter(filterValue, filterKeys)) return
ul.appendChild(addFeature(feature))
})
}
@ -788,148 +784,72 @@ L.U.Map.include({
L.bind(appendAll, this)()
L.DomEvent.on(filter, 'input', appendAll, this)
L.DomEvent.on(filter, 'input', resetLayers, this)
const link = L.DomUtil.create('li', '')
L.DomUtil.create('i', 'umap-icon-16 umap-caption', link)
const label = L.DomUtil.create('span', '', link)
label.textContent = label.title = L._('About')
L.DomEvent.on(link, 'click', this.displayCaption, this)
this.ui.openPanel({ data: { html: browserContainer }, actions: [link] })
this.ui.openPanel({
data: { html: browserContainer },
actions: [this._aboutLink()],
})
},
_openFilter: function () {
const filterContainer = L.DomUtil.create('div', 'umap-filter-data'),
title = L.DomUtil.add(
'h3',
'umap-filter-title',
filterContainer,
this.options.name
),
propertiesContainer = L.DomUtil.create(
'div',
'umap-filter-properties',
filterContainer
),
advancedFilterKeys = this.getAdvancedFilterKeys()
_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 advancedFiltersFull = {}
let filtersAlreadyLoaded = true
if (!this.getMap().options.advancedFilters) {
this.getMap().options.advancedFilters = {}
filtersAlreadyLoaded = false
}
advancedFilterKeys.forEach((property) => {
advancedFiltersFull[property] = []
if (!filtersAlreadyLoaded || !this.getMap().options.advancedFilters[property]) {
this.getMap().options.advancedFilters[property] = []
}
const knownValues = {}
keys.forEach((key) => {
knownValues[key] = []
if (!this.facets[key]) this.facets[key] = []
})
this.eachBrowsableDataLayer((datalayer) => {
datalayer.eachFeature((feature) => {
advancedFilterKeys.forEach((property) => {
if (feature.properties[property]) {
if (!advancedFiltersFull[property].includes(feature.properties[property])) {
advancedFiltersFull[property].push(feature.properties[property])
}
keys.forEach((key) => {
let value = feature.properties[key]
if (typeof value !== 'undefined' && !knownValues[key].includes(value)) {
knownValues[key].push(value)
}
})
})
})
const addPropertyValue = function (property, value) {
const property_li = L.DomUtil.create('li', ''),
filter_check = L.DomUtil.create('input', '', property_li),
filter_label = L.DomUtil.create('label', '', property_li)
filter_check.type = 'checkbox'
filter_check.id = `checkbox_${property}_${value}`
filter_check.checked =
this.getMap().options.advancedFilters[property] &&
this.getMap().options.advancedFilters[property].includes(value)
filter_check.setAttribute('data-property', property)
filter_check.setAttribute('data-value', value)
filter_label.htmlFor = `checkbox_${property}_${value}`
filter_label.innerHTML = value
L.DomEvent.on(
filter_check,
'change',
function (e) {
const property = e.srcElement.dataset.property
const value = e.srcElement.dataset.value
if (e.srcElement.checked) {
this.getMap().options.advancedFilters[property].push(value)
} else {
this.getMap().options.advancedFilters[property].splice(
this.getMap().options.advancedFilters[property].indexOf(value),
1
)
}
L.bind(filterFeatures, this)()
},
this
)
return property_li
}
const addProperty = function (property) {
const container = L.DomUtil.create(
'div',
'property-container',
propertiesContainer
),
headline = L.DomUtil.add('h5', '', container, property)
const ul = L.DomUtil.create('ul', '', container)
const orderedValues = advancedFiltersFull[property]
orderedValues.sort()
orderedValues.forEach((value) => {
ul.appendChild(L.bind(addPropertyValue, this)(property, value))
})
}
const filterFeatures = function () {
let noResults = true
let found = false
this.eachBrowsableDataLayer((datalayer) => {
datalayer.eachFeature(function (feature) {
feature.properties.isVisible = true
for (const [property, values] of Object.entries(
this.map.options.advancedFilters
)) {
if (values.length > 0) {
if (
!feature.properties[property] ||
!values.includes(feature.properties[property])
) {
feature.properties.isVisible = false
}
}
}
if (feature.properties.isVisible) {
noResults = false
if (!this.isLoaded()) this.fetchData()
this.map.addLayer(feature)
this.fire('show')
} else {
this.map.removeLayer(feature)
this.fire('hide')
}
})
datalayer.resetLayer(true)
if (datalayer.hasDataVisible()) found = true
})
if (noResults) {
this.help.show('advancedFiltersNoResults')
} else {
this.help.hide()
}
// TODO: display a results counter in the panel instead.
if (!found)
this.ui.alert({ content: L._('No results for these facets'), level: 'info' })
}
propertiesContainer.innerHTML = ''
advancedFilterKeys.forEach((property) => {
L.bind(addProperty, this)(property)
const fields = keys.map((current) => [
`facets.${current}`,
{
handler: 'FacetSearch',
choices: knownValues[current],
label: this.getFacetKeys()[current],
},
])
const builder = new L.U.FormBuilder(this, fields, {
makeDirty: false,
callback: filterFeatures,
callbackContext: this,
})
container.appendChild(builder.build())
this.ui.openPanel({ data: { html: container }, actions: [this._aboutLink()] })
},
_aboutLink: function () {
const link = L.DomUtil.create('li', '')
L.DomUtil.create('i', 'umap-icon-16 umap-caption', link)
const label = L.DomUtil.create('span', '', link)
label.textContent = label.title = L._('About')
L.DomEvent.on(link, 'click', this.displayCaption, this)
this.ui.openPanel({ data: { html: filterContainer }, actions: [link] })
return link
},
displayCaption: function () {
@ -1011,11 +931,11 @@ L.U.Map.include({
labelBrowser.textContent = labelBrowser.title = L._('Browse data')
L.DomEvent.on(browser, 'click', this.openBrowser, this)
const actions = [browser]
if (this.options.advancedFilterKey) {
if (this.options.facetKey) {
const filter = L.DomUtil.create('li', '')
L.DomUtil.create('i', 'umap-icon-16 umap-add', filter)
const labelFilter = L.DomUtil.create('span', '', filter)
labelFilter.textContent = labelFilter.title = L._('Select data')
labelFilter.textContent = labelFilter.title = L._('Facet search')
L.DomEvent.on(filter, 'click', this.openFilter, this)
actions.push(filter)
}

View file

@ -568,10 +568,9 @@ L.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'),
advancedFilterKey: L._(
'Comma separated list of properties to use for checkbox filtering'
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)'
),
advancedFiltersNoResults: L._('No results for these filters'),
interactive: L._('If false, the polygon will act as a part of the underlying map.'),
outlink: L._('Define link to open in a new window on polygon click.'),
dynamicRemoteData: L._('Fetch data each time map view changes.'),

View file

@ -87,7 +87,12 @@ L.U.FeatureMixin = {
edit: function (e) {
if (!this.map.editEnabled || this.isReadOnly()) return
const container = L.DomUtil.create('div', 'umap-feature-container')
L.DomUtil.add('h3', `umap-feature-properties ${this.getClassName()}`, container, L._('Feature properties'))
L.DomUtil.add(
'h3',
`umap-feature-properties ${this.getClassName()}`,
container,
L._('Feature properties')
)
let builder = new L.U.FormBuilder(this, ['datalayer'], {
callback: function () {
@ -469,6 +474,17 @@ L.U.FeatureMixin = {
return false
},
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
}
}
return true
},
onVertexRawClick: function (e) {
new L.Toolbar.Popup(e.latlng, {
className: 'leaflet-inplace-toolbar',

View file

@ -431,7 +431,7 @@ L.FormBuilder.OnLoadPanel = L.FormBuilder.Select.extend({
['none', L._('None')],
['caption', L._('Caption')],
['databrowser', L._('Data browser')],
['datafilters', L._('Data filters')],
['facet', L._('Facet search')],
],
})
@ -684,6 +684,37 @@ L.FormBuilder.Switch = L.FormBuilder.CheckBox.extend({
},
})
L.FormBuilder.FacetSearch = 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
choices.sort()
choices.forEach((value) => this.buildLi(value))
},
buildLabel: function () {
this.label = L.DomUtil.add('h5', '', this.parentNode, 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)
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)
},
})
L.FormBuilder.MultiChoice = L.FormBuilder.Element.extend({
default: 'null',
className: 'umap-multiplechoice',
@ -1125,7 +1156,7 @@ L.U.FormBuilder = L.FormBuilder.extend({
setter: function (field, value) {
L.FormBuilder.prototype.setter.call(this, field, value)
this.obj.isDirty = true
if (this.options.makeDirty !== false) this.obj.isDirty = true
},
finish: function () {

View file

@ -151,12 +151,14 @@ L.U.Map.include({
this.options.slideshow.active === undefined
)
this.options.slideshow.active = true
if (this.options.advancedFilterKey) this.options.facetKey = this.options.advancedFilterKey
// Global storage for retrieving datalayers and features
this.datalayers = {}
this.datalayers_index = []
this.dirty_datalayers = []
this.features_index = {}
this.facets = {}
if (this.options.hash) this.addHash()
this.initControls()
@ -250,7 +252,7 @@ L.U.Map.include({
if (L.Util.queryString('share')) this.renderShareBox()
else if (this.options.onLoadPanel === 'databrowser') this.openBrowser()
else if (this.options.onLoadPanel === 'caption') this.displayCaption()
else if (this.options.onLoadPanel === 'datafilters') this.openFilter()
else if (this.options.onLoadPanel === 'facet' || this.options.onLoadPanel === 'datafilters') this.openFacet()
})
this.onceDataLoaded(function () {
const slug = L.Util.queryString('feature')
@ -953,9 +955,9 @@ L.U.Map.include({
})
},
openFilter: function () {
openFacet: function () {
this.onceDatalayersLoaded(function () {
this._openFilter()
this._openFacet()
})
},
@ -1067,7 +1069,7 @@ L.U.Map.include({
'sortKey',
'labelKey',
'filterKey',
'advancedFilterKey',
'facetKey',
'slugKey',
'showLabel',
'labelDirection',
@ -1394,13 +1396,12 @@ L.U.Map.include({
},
],
[
'options.advancedFilterKey',
'options.facetKey',
{
handler: 'Input',
helpEntries: 'advancedFilterKey',
helpEntries: 'facetKey',
placeholder: L._('Example: key1,key2,key3'),
label: L._('Advanced filter keys'),
inheritable: true,
label: L._('Facet keys')
},
],
[
@ -1785,7 +1786,7 @@ L.U.Map.include({
this.openBrowser,
this
)
if (this.options.advancedFilterKey) {
if (this.options.facetKey) {
const filter = L.DomUtil.add(
'a',
'umap-open-filter-link',
@ -1796,7 +1797,7 @@ L.U.Map.include({
L.DomEvent.on(filter, 'click', L.DomEvent.stop).on(
filter,
'click',
this.openFilter,
this.openFacet,
this
)
}
@ -2014,10 +2015,10 @@ L.U.Map.include({
text: L._('Browse data'),
callback: this.openBrowser,
})
if (this.options.advancedFilterKey) {
if (this.options.facetKey) {
items.push({
text: L._('Select data'),
callback: this.openFilter,
text: L._('Facet search'),
callback: this.openFacet,
})
}
items.push(
@ -2102,8 +2103,12 @@ L.U.Map.include({
return (this.options.filterKey || this.options.sortKey || 'name').split(',')
},
getAdvancedFilterKeys: function () {
return (this.options.advancedFilterKey || '').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 () {

View file

@ -10,6 +10,10 @@ L.U.Layer = {
},
postUpdate: function () {},
hasDataVisible: function () {
return !!Object.keys(this._layers).length
},
}
L.U.Layer.Default = L.FeatureGroup.extend({
@ -53,6 +57,17 @@ L.U.Layer.Cluster = L.MarkerClusterGroup.extend({
}
L.MarkerClusterGroup.prototype.initialize.call(this, options)
this._markerCluster = L.U.MarkerCluster
this._layers = []
},
addLayer: function (layer) {
this._layers.push(layer)
return L.MarkerClusterGroup.prototype.addLayer.call(this, layer)
},
removeLayer: function (layer) {
this._layers.splice(this._layers.indexOf(layer), 1)
return L.MarkerClusterGroup.prototype.removeLayer.call(this, layer)
},
getEditableOptions: function () {
@ -285,6 +300,10 @@ L.U.DataLayer = L.Evented.extend({
this.parentPane.appendChild(this.pane)
},
hasDataVisible: function () {
return this.layer.hasDataVisible()
},
resetLayer: function (force) {
if (this.layer && this.options.type === this.layer._type && !force) return
const visible = this.isVisible()
@ -293,12 +312,7 @@ L.U.DataLayer = L.Evented.extend({
if (visible) this.map.removeLayer(this.layer)
const Class = L.U.Layer[this.options.type] || L.U.Layer.Default
this.layer = new Class(this)
const filterKeys = this.map.getFilterKeys(),
filter = this.map.options.filter
this.eachLayer(function (layer) {
if (filter && !layer.matchFilter(filter, filterKeys)) return
this.layer.addLayer(layer)
})
this.eachLayer((feature) => this.showFeature(feature))
if (visible) this.map.addLayer(this.layer)
this.propagateRemote()
},
@ -478,15 +492,23 @@ L.U.DataLayer = L.Evented.extend({
return this.options.type === 'Cluster'
},
showFeature: function (feature) {
const filterKeys = this.map.getFilterKeys(),
filter = this.map.options.filter
if (filter && !feature.matchFilter(filter, filterKeys)) return
if (!feature.matchFacets()) return
this.layer.addLayer(feature)
},
addLayer: function (feature) {
const id = L.stamp(feature)
feature.connectToDataLayer(this)
this._index.push(id)
this._layers[id] = feature
this.layer.addLayer(feature)
this.indexProperties(feature)
if (this.hasDataLoaded()) this.fire('datachanged')
this.map.features_index[feature.getSlug()] = feature
this.showFeature(feature)
if (this.hasDataLoaded()) this.fire('datachanged')
},
removeLayer: function (feature) {

View file

@ -800,17 +800,16 @@ a.add-datalayer:hover,
/* Features browser panel */
/* ********************************* */
.umap-facet-search .formbox,
.umap-browse-features > div {
border: 1px solid #d3d3d3;
margin-bottom: 14px;
border-radius: 2px;
}
.umap-browse-features h5, .umap-filter-data h5 {
.umap-browse-features h5, .umap-facet-search h5 {
margin-bottom: 0;
overflow: hidden;
padding-left: 5px;
}
.umap-browse-features h5 {
height: 30px;
line-height: 30px;
background-color: #eeeee0;
@ -819,14 +818,13 @@ a.add-datalayer:hover,
.umap-browse-features h5 span {
margin-left: 10px;
}
.umap-browse-features li {
.umap-browse-features li, .umap-facet-search li {
padding: 2px 0;
}
.umap-browse-features li, .umap-filter-data li {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.umap-facet-search li:nth-child(even),
.umap-browse-features li:nth-child(even) {
background-color: #f8f8f3;
}
@ -855,9 +853,6 @@ a.add-datalayer:hover,
.umap-browse-features .polygon .feature-color {
background-position: -32px -16px;
}
.umap-filter-data .property-container:not(:first-child) {
margin-top: 14px;
}
.show-on-edit {
display: none!important;
}

View file

@ -403,22 +403,22 @@ describe('L.U.DataLayer', function () {
assert.ok(qs('path[fill="DarkGoldenRod"]'))
})
})
describe('#advanced-filters()', function () {
describe('#facet-search()', function () {
before(function () {
this.server.respondWith(
/\/datalayer\/63\/\?.*/,
JSON.stringify(RESPONSES.datalayer63_GET)
)
this.map.options.advancedFilterKey = 'name'
this.map.options.facetKey = 'name'
this.map.createDataLayer(RESPONSES.datalayer63_GET._umap_options)
this.server.respond()
})
it('should show non browsable layer', function () {
it('should not impact non browsable layer', function () {
assert.ok(qs('path[fill="SteelBlue"]'))
})
it('should allow advanced filter', function () {
this.map.openFilter()
assert.ok(qs('div.umap-filter-properties'))
this.map.openFacet()
assert.ok(qs('div.umap-facet-search'))
// This one if from the normal datalayer
// it's name is "test", so it should be hidden
// by the filter
@ -428,10 +428,15 @@ describe('L.U.DataLayer', function () {
// This one comes from a non browsable layer
// so it should not be affected by the filter
assert.ok(qs('path[fill="SteelBlue"]'))
happen.click(qs('input[data-value="name poly"]')) // Undo
happen.click(qs('input[data-value="name poly"]')) // Undo
})
it('should allow to control facet label', function () {
this.map.options.facetKey = 'name|Nom'
this.map.openFacet()
assert.ok(qs('div.umap-facet-search h5'))
assert.equal(qs('div.umap-facet-search h5').textContent, 'Nom')
})
})
describe('#zoomEnd', function () {
it('should honour the fromZoom option', function () {
this.map.setZoom(6, {animate: false})
@ -453,5 +458,4 @@ describe('L.U.DataLayer', function () {
assert.ok(qs('path[fill="none"]'))
})
})
})