diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index 32ddccdb..6e3828f7 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -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; } diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 2b8d20f0..43260c1f 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -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) } diff --git a/umap/static/umap/js/umap.core.js b/umap/static/umap/js/umap.core.js index 0a3cb7ac..2a9aeb03 100644 --- a/umap/static/umap/js/umap.core.js +++ b/umap/static/umap/js/umap.core.js @@ -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.'), diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 56cd620e..b22e1491 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -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', diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index bb8e73a4..e3569a2d 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -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 () { diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 10f82db3..184e33e9 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -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 () { diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 09317e7e..590d69d3 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -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) { diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index ef430474..852e4bdc 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -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; } diff --git a/umap/static/umap/test/DataLayer.js b/umap/static/umap/test/DataLayer.js index 59d9b1ad..85c91628 100644 --- a/umap/static/umap/test/DataLayer.js +++ b/umap/static/umap/test/DataLayer.js @@ -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"]')) }) }) - })