From 05eab25da4799cc6c4a2f16ca4dd48a0a8348300 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 25 Apr 2024 16:30:46 +0200 Subject: [PATCH 01/14] feat: very minimal experimental conditional style rules --- umap/static/umap/js/modules/global.js | 2 + umap/static/umap/js/modules/rules.js | 175 ++++++++++++++++++++++++++ umap/static/umap/js/modules/schema.js | 4 + umap/static/umap/js/umap.js | 13 +- umap/static/umap/js/umap.layer.js | 2 +- 5 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 umap/static/umap/js/modules/rules.js diff --git a/umap/static/umap/js/modules/global.js b/umap/static/umap/js/modules/global.js index f42ce016..49f2822b 100644 --- a/umap/static/umap/js/modules/global.js +++ b/umap/static/umap/js/modules/global.js @@ -5,6 +5,7 @@ import Caption from './caption.js' import { Panel, EditPanel, FullPanel } from './ui/panel.js' import Dialog from './ui/dialog.js' import Tooltip from './ui/tooltip.js' +import Rules from './rules.js' import * as Utils from './utils.js' import { SCHEMA } from './schema.js' import { Request, ServerRequest, RequestError, HTTPError, NOKError } from './request.js' @@ -43,6 +44,7 @@ window.U = { Panel, Request, RequestError, + Rules, SCHEMA, ServerRequest, SyncEngine, diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js new file mode 100644 index 00000000..0c60dbe1 --- /dev/null +++ b/umap/static/umap/js/modules/rules.js @@ -0,0 +1,175 @@ +import { DomUtil, DomEvent, stamp } from '../../vendors/leaflet/leaflet-src.esm.js' +import * as Utils from './utils.js' +import { translate } from './i18n.js' + +class Rule { + constructor(map, condition = '', options = {}) { + this.map = map + this.condition = condition + this.parse() + this.options = options + let isDirty = false + Object.defineProperty(this, 'isDirty', { + get: () => { + return isDirty + }, + set: (status) => { + isDirty = status + if (status) { + this.map.isDirty = status + } + }, + }) + } + + render(fields) { + this.map.render(fields) + } + + parse() { + let vars = [] + if (this.condition.includes('!=')) { + this.operator = (our, other) => our != other + vars = this.condition.split('!=') + } else if (this.condition.includes('=')) { + this.operator = (our, other) => our === other + vars = this.condition.split('=') + } + if (vars.length != 2) this.operator = undefined + this.key = vars[0] + this.expected = vars[1] + } + + match(props) { + if (!this.operator) return false + return this.operator(this.expected, props[this.key]) + } + + getMap() { + return this.map + } + + getOption(option) { + return this.options[option] + } + + edit() { + const options = [ + [ + 'condition', + { + handler: 'BlurInput', + label: L._('Condition'), + placeholder: translate('key=value or key!=value'), + }, + ], + 'options.color', + 'options.iconClass', + 'options.iconUrl', + 'options.iconOpacity', + 'options.opacity', + 'options.weight', + 'options.fill', + 'options.fillColor', + 'options.fillOpacity', + 'options.smoothFactor', + 'options.dashArray', + ] + const container = DomUtil.create('div') + const builder = new U.FormBuilder(this, options) + const defaultShapeProperties = L.DomUtil.add('div', '', container) + defaultShapeProperties.appendChild(builder.build()) + + this.map.editPanel.open({ content: container }) + } + + renderToolbox(container) { + const toggle = L.DomUtil.createButtonIcon( + container, + 'icon-eye', + L._('Show/hide layer') + ) + const edit = L.DomUtil.createButtonIcon( + container, + 'icon-edit show-on-edit', + L._('Edit') + ) + const remove = L.DomUtil.createButtonIcon( + container, + 'icon-delete show-on-edit', + L._('Delete layer') + ) + L.DomEvent.on(edit, 'click', this.edit, this) + L.DomEvent.on( + remove, + 'click', + function () { + if (!confirm(L._('Are you sure you want to delete this rule?'))) return + this._delete() + this.map.editPanel.close() + }, + this + ) + DomUtil.add('span', '', container, this.condition || translate('empty rule')) + //L.DomEvent.on(toggle, 'click', this.toggle, this) + } + + _delete() { + this.map.rules.rules = this.map.rules.rules.filter((rule) => rule != this) + } +} + +export default class Rules { + constructor(map) { + this.map = map + this.rules = [] + this.loadRules() + } + + loadRules() { + if (!this.map.options.rules?.length) return + for (const { condition, options } of this.map.options.rules) { + if (!condition) continue + this.rules.push(new Rule(this.map, condition, options)) + } + } + + edit(container) { + const body = L.DomUtil.createFieldset( + container, + translate('Conditional style rules') + ) + if (this.rules.length) { + const list = DomUtil.create('ul', '', body) + for (const rule of this.rules) { + rule.renderToolbox(DomUtil.create('li', '', list)) + } + } + L.DomUtil.createButton('umap-add', body, translate('Add rule'), this.addRule, this) + } + + addRule() { + const rule = new Rule(this.map) + rule.isDirty = true + this.rules.push(rule) + rule.edit(map) + } + + commit() { + this.map.options.rules = this.rules.map((rule) => { + return { + condition: rule.condition, + options: rule.options, + } + }) + } + + getOption(option, feature) { + for (const rule of this.rules) { + if (rule.match(feature.properties)) { + if (Utils.usableOption(rule.options, option)) return rule.options[option] + break + } + } + } +} diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js index 07e520bb..694a4512 100644 --- a/umap/static/umap/js/modules/schema.js +++ b/umap/static/umap/js/modules/schema.js @@ -386,6 +386,10 @@ export const SCHEMA = { type: Object, impacts: ['remote-data'], }, + rules: { + type: Object, + impacts: ['data'], + }, scaleControl: { type: Boolean, impacts: ['ui'], diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 9cc41025..13702036 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -419,6 +419,7 @@ U.Map = L.Map.extend({ this.importer = new U.Importer(this) this.drop = new U.DropControl(this) this.share = new U.Share(this) + this.rules = new U.Rules(this) this._controls.tilelayers = new U.TileLayerControl(this) }, @@ -823,7 +824,15 @@ U.Map = L.Map.extend({ return U.SCHEMA[option] && U.SCHEMA[option].default }, - getOption: function (option) { + getRuleOption: function (option, feature) { + return this.rules.getOption(option, feature) + }, + + getOption: function (option, feature) { + if (feature) { + const value = this.getRuleOption(option, feature) + if (value !== undefined) return value + } if (U.Utils.usableOption(this.options, option)) return this.options[option] return this.getDefaultOption(option) }, @@ -1031,6 +1040,7 @@ U.Map = L.Map.extend({ }, saveSelf: async function () { + this.rules.commit() const geojson = { type: 'Feature', geometry: this.geometry(), @@ -1561,6 +1571,7 @@ U.Map = L.Map.extend({ this._editShapeProperties(container) this._editDefaultProperties(container) this._editInteractionsProperties(container) + this.rules.edit(container) this._editTilelayer(container) this._editOverlay(container) this._editBounds(container) diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index faf2d5d4..177f278a 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -1496,7 +1496,7 @@ U.DataLayer = L.Evented.extend({ } else if (this.layer && this.layer.defaults && this.layer.defaults[option]) { return this.layer.defaults[option] } else { - return this.map.getOption(option) + return this.map.getOption(option, feature) } }, From 4f8e45301279926338b00f57e895b71057a025fd Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 25 Apr 2024 17:16:12 +0200 Subject: [PATCH 02/14] wip: allow to reorder rules --- umap/static/umap/js/modules/rules.js | 45 ++++++++++++++++++---------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index 0c60dbe1..de77cc30 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -83,19 +83,11 @@ class Rule { this.map.editPanel.open({ content: container }) } - renderToolbox(container) { - const toggle = L.DomUtil.createButtonIcon( - container, - 'icon-eye', - L._('Show/hide layer') - ) - const edit = L.DomUtil.createButtonIcon( - container, - 'icon-edit show-on-edit', - L._('Edit') - ) + renderToolbox(row) { + const toggle = L.DomUtil.createButtonIcon(row, 'icon-eye', L._('Show/hide layer')) + const edit = L.DomUtil.createButtonIcon(row, 'icon-edit show-on-edit', L._('Edit')) const remove = L.DomUtil.createButtonIcon( - container, + row, 'icon-delete show-on-edit', L._('Delete layer') ) @@ -110,7 +102,9 @@ class Rule { }, this ) - DomUtil.add('span', '', container, this.condition || translate('empty rule')) + DomUtil.add('span', '', row, this.condition || translate('empty rule')) + L.DomUtil.createIcon(row, 'icon-drag', L._('Drag to reorder')) + row.dataset.id = stamp(this) //L.DomEvent.on(toggle, 'click', this.toggle, this) } @@ -134,17 +128,38 @@ export default class Rules { } } + onReorder(src, dst, initialIndex, finalIndex) { + const moved = this.rules.find((rule) => stamp(rule) == src.dataset.id) + const reference = this.rules.find((rule) => stamp(rule) == dst.dataset.id) + const movedIdx = this.rules.indexOf(moved) + let referenceIdx = this.rules.indexOf(reference) + const minIndex = Math.min(movedIdx, referenceIdx) + const maxIndex = Math.max(movedIdx, referenceIdx) + moved._delete() // Remove from array + referenceIdx = this.rules.indexOf(reference) + let newIdx + if (finalIndex === 0) newIdx = 0 + else if (finalIndex > initialIndex) newIdx = referenceIdx + else newIdx = referenceIdx + 1 + this.rules.splice(newIdx, 0, moved) + moved.isDirty = true + this.map.render(['rules']) + } + edit(container) { const body = L.DomUtil.createFieldset( container, translate('Conditional style rules') ) if (this.rules.length) { - const list = DomUtil.create('ul', '', body) + const ul = DomUtil.create('ul', '', body) for (const rule of this.rules) { - rule.renderToolbox(DomUtil.create('li', '', list)) + rule.renderToolbox(DomUtil.create('li', 'orderable', ul)) } + + const orderable = new U.Orderable(ul, this.onReorder.bind(this)) } + L.DomUtil.createButton('umap-add', body, translate('Add rule'), this.addRule, this) } From a2d04b9ad4160350d31e888ec7dc27b32bf40f24 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 26 Apr 2024 10:53:14 +0200 Subject: [PATCH 03/14] wip: use direct imports instead of L. global --- umap/static/umap/js/modules/rules.js | 32 +++++++++++++++++----------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index de77cc30..59e57951 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -59,7 +59,7 @@ class Rule { 'condition', { handler: 'BlurInput', - label: L._('Condition'), + label: translate('Condition'), placeholder: translate('key=value or key!=value'), }, ], @@ -77,33 +77,41 @@ class Rule { ] const container = DomUtil.create('div') const builder = new U.FormBuilder(this, options) - const defaultShapeProperties = L.DomUtil.add('div', '', container) + const defaultShapeProperties = DomUtil.add('div', '', container) defaultShapeProperties.appendChild(builder.build()) this.map.editPanel.open({ content: container }) } renderToolbox(row) { - const toggle = L.DomUtil.createButtonIcon(row, 'icon-eye', L._('Show/hide layer')) - const edit = L.DomUtil.createButtonIcon(row, 'icon-edit show-on-edit', L._('Edit')) - const remove = L.DomUtil.createButtonIcon( + const toggle = DomUtil.createButtonIcon( + row, + 'icon-eye', + translate('Show/hide layer') + ) + const edit = DomUtil.createButtonIcon( + row, + 'icon-edit show-on-edit', + translate('Edit') + ) + const remove = DomUtil.createButtonIcon( row, 'icon-delete show-on-edit', - L._('Delete layer') + translate('Delete layer') ) - L.DomEvent.on(edit, 'click', this.edit, this) - L.DomEvent.on( + DomEvent.on(edit, 'click', this.edit, this) + DomEvent.on( remove, 'click', function () { - if (!confirm(L._('Are you sure you want to delete this rule?'))) return + if (!confirm(translate('Are you sure you want to delete this rule?'))) return this._delete() this.map.editPanel.close() }, this ) DomUtil.add('span', '', row, this.condition || translate('empty rule')) - L.DomUtil.createIcon(row, 'icon-drag', L._('Drag to reorder')) + DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder')) row.dataset.id = stamp(this) //L.DomEvent.on(toggle, 'click', this.toggle, this) } @@ -147,7 +155,7 @@ export default class Rules { } edit(container) { - const body = L.DomUtil.createFieldset( + const body = DomUtil.createFieldset( container, translate('Conditional style rules') ) @@ -160,7 +168,7 @@ export default class Rules { const orderable = new U.Orderable(ul, this.onReorder.bind(this)) } - L.DomUtil.createButton('umap-add', body, translate('Add rule'), this.addRule, this) + DomUtil.createButton('umap-add', body, translate('Add rule'), this.addRule, this) } addRule() { From 805c09e34edc4c99986a01cef9a00033d019512a Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 26 Apr 2024 10:57:42 +0200 Subject: [PATCH 04/14] wip: allow to deactivate a conditional rule from list --- umap/static/umap/js/modules/rules.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index 59e57951..0039aefa 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -6,6 +6,7 @@ class Rule { constructor(map, condition = '', options = {}) { this.map = map this.condition = condition + this.active = true this.parse() this.options = options let isDirty = false @@ -41,7 +42,7 @@ class Rule { } match(props) { - if (!this.operator) return false + if (!this.operator || !this.active) return false return this.operator(this.expected, props[this.key]) } @@ -84,6 +85,7 @@ class Rule { } renderToolbox(row) { + row.classList.toggle('off', !this.active) const toggle = DomUtil.createButtonIcon( row, 'icon-eye', @@ -113,7 +115,11 @@ class Rule { DomUtil.add('span', '', row, this.condition || translate('empty rule')) DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder')) row.dataset.id = stamp(this) - //L.DomEvent.on(toggle, 'click', this.toggle, this) + DomEvent.on(toggle, 'click', () => { + this.active = !this.active + row.classList.toggle('off', !this.active) + this.map.render(['rules']) + }) } _delete() { From d6ae4744a6770f1c7d7e85d8c7088784a4a660cd Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 26 Apr 2024 11:14:00 +0200 Subject: [PATCH 05/14] wip: make sure we update rule when condition is changed And make sure we redraw data on map --- umap/static/umap/js/modules/rules.js | 13 +++++++++++-- umap/static/umap/js/modules/schema.js | 4 ++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index 0039aefa..bc6e6acc 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -5,9 +5,7 @@ import { translate } from './i18n.js' class Rule { constructor(map, condition = '', options = {}) { this.map = map - this.condition = condition this.active = true - this.parse() this.options = options let isDirty = false Object.defineProperty(this, 'isDirty', { @@ -21,6 +19,17 @@ class Rule { } }, }) + let _condition + Object.defineProperty(this, 'condition', { + get: () => { + return _condition + }, + set: (value) => { + _condition = value + this.parse() + }, + }) + this.condition = condition } render(fields) { diff --git a/umap/static/umap/js/modules/schema.js b/umap/static/umap/js/modules/schema.js index 694a4512..1a2abda3 100644 --- a/umap/static/umap/js/modules/schema.js +++ b/umap/static/umap/js/modules/schema.js @@ -57,6 +57,10 @@ export const SCHEMA = { type: Object, impacts: ['data'], }, + condition: { + type: String, + impacts: ['data'], + }, dashArray: { type: String, impacts: ['data'], From 129f46dd6d614c58cfd11b8ec607df611e133f6f Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 26 Apr 2024 13:38:56 +0200 Subject: [PATCH 06/14] wip: add minimal tests for conditional rules --- .../integration/test_conditional_rules.py | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 umap/tests/integration/test_conditional_rules.py diff --git a/umap/tests/integration/test_conditional_rules.py b/umap/tests/integration/test_conditional_rules.py new file mode 100644 index 00000000..f754bd80 --- /dev/null +++ b/umap/tests/integration/test_conditional_rules.py @@ -0,0 +1,142 @@ +import pytest +from playwright.sync_api import expect + +from ..base import DataLayerFactory + +pytestmark = pytest.mark.django_db + + +def getColors(elements): + return [ + el.evaluate("e => window.getComputedStyle(e).backgroundColor") + for el in elements.all() + ] + + +DATALAYER_DATA1 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "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", + "mynumber": 12, + "mydate": "2024/03/13 12:20:20", + }, + "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]}, + }, + ], + "_umap_options": { + "name": "Calque 1", + }, +} + + +DATALAYER_DATA2 = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "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", + "mynumber": 14, + "mydate": "2024-04-14T10:19:17.000Z", + }, + "geometry": {"type": "Point", "coordinates": [4.372559, 47.945786]}, + }, + ], + "_umap_options": { + "name": "Calque 2", + }, +} + + +def test_simple_simple_equal_rule_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "mytype=odd", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(4) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 2 + + +def test_simple_simple_not_equal_rule_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "mytype!=even", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(4) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 2 + + +def test_can_create_new_rule(live_server, page, openmap): + DataLayerFactory(map=openmap, data=DATALAYER_DATA1) + DataLayerFactory(map=openmap, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(4) + page.get_by_role("button", name="Edit").click() + page.get_by_role("link", name="Map advanced properties").click() + page.get_by_role("heading", name="Conditional style rules").click() + page.get_by_role("button", name="Add rule").click() + page.locator("input[name=condition]").click() + page.locator("input[name=condition]").fill("mytype=odd") + page.locator(".umap-field-color .define").first.click() + page.get_by_title("AliceBlue").first.click() + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 2 + + +def test_can_deactive_rule_from_list(live_server, page, openmap): + openmap.settings["properties"]["rules"] = [ + {"condition": "mytype=odd", "options": {"color": "aliceblue"}} + ] + openmap.save() + DataLayerFactory(map=openmap, data=DATALAYER_DATA1) + DataLayerFactory(map=openmap, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{openmap.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(4) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 2 + page.get_by_role("button", name="Edit").click() + page.get_by_role("link", name="Map advanced properties").click() + page.get_by_role("heading", name="Conditional style rules").click() + page.get_by_role("button", name="Show/hide layer").click() + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 0 + page.get_by_role("button", name="Show/hide layer").click() + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 2 From 5ec944fce0c63a7bc02f96bffffc638fc74cb00d Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 26 Apr 2024 14:37:55 +0200 Subject: [PATCH 07/14] wip: deal with gt/lt and numbers in conditional rules --- umap/static/umap/js/modules/rules.js | 50 ++++++++++++++----- .../integration/test_conditional_rules.py | 46 ++++++++++++++++- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index bc6e6acc..b065bc7a 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -36,23 +36,52 @@ class Rule { this.map.render(fields) } + equal(other) { + return this.expected === other + } + + not_equal(other) { + return this.expected != other + } + + gt(other) { + return other > this.expected + } + + lt(other) { + return other < this.expected + } + + OPERATORS = [ + ['>', this.gt], + ['<', this.lt], + // When sent by Django + ['<', this.lt], + ['!=', this.not_equal], + ['=', this.equal], + ] + parse() { let vars = [] - if (this.condition.includes('!=')) { - this.operator = (our, other) => our != other - vars = this.condition.split('!=') - } else if (this.condition.includes('=')) { - this.operator = (our, other) => our === other - vars = this.condition.split('=') + this.cast = (v) => v + this.operator = undefined + for (const [sign, func] of this.OPERATORS) { + if (this.condition.includes(sign)) { + this.operator = func + vars = this.condition.split(sign) + break + } } - if (vars.length != 2) this.operator = undefined + if (vars.length != 2) return this.key = vars[0] this.expected = vars[1] + if (!isNaN(this.expected)) this.cast = parseFloat + this.expected = this.cast(this.expected) } match(props) { if (!this.operator || !this.active) return false - return this.operator(this.expected, props[this.key]) + return this.operator(this.cast(props[this.key])) } getMap() { @@ -170,10 +199,7 @@ export default class Rules { } edit(container) { - const body = DomUtil.createFieldset( - container, - translate('Conditional style rules') - ) + const body = DomUtil.createFieldset(container, translate('Conditional style rules')) if (this.rules.length) { const ul = DomUtil.create('ul', '', body) for (const rule of this.rules) { diff --git a/umap/tests/integration/test_conditional_rules.py b/umap/tests/integration/test_conditional_rules.py index f754bd80..6170cba9 100644 --- a/umap/tests/integration/test_conditional_rules.py +++ b/umap/tests/integration/test_conditional_rules.py @@ -73,7 +73,7 @@ DATALAYER_DATA2 = { } -def test_simple_simple_equal_rule_at_load(live_server, page, map): +def test_simple_equal_rule_at_load(live_server, page, map): map.settings["properties"]["rules"] = [ {"condition": "mytype=odd", "options": {"color": "aliceblue"}} ] @@ -87,7 +87,7 @@ def test_simple_simple_equal_rule_at_load(live_server, page, map): assert colors.count("rgb(240, 248, 255)") == 2 -def test_simple_simple_not_equal_rule_at_load(live_server, page, map): +def test_simple_not_equal_rule_at_load(live_server, page, map): map.settings["properties"]["rules"] = [ {"condition": "mytype!=even", "options": {"color": "aliceblue"}} ] @@ -101,6 +101,48 @@ def test_simple_simple_not_equal_rule_at_load(live_server, page, map): assert colors.count("rgb(240, 248, 255)") == 2 +def test_gt_rule_with_number_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "mynumber>10", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(4) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 2 + + +def test_lt_rule_with_number_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "mynumber<14", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(4) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 3 + + +def test_lt_rule_with_float_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "mynumber<12.3", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(4) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 3 + + def test_can_create_new_rule(live_server, page, openmap): DataLayerFactory(map=openmap, data=DATALAYER_DATA1) DataLayerFactory(map=openmap, data=DATALAYER_DATA2) From 7367de5545dd434e5845ec4bea99fb4b80dba78b Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Fri, 26 Apr 2024 14:44:13 +0200 Subject: [PATCH 08/14] wip: deal with boolean values in conditional rules --- umap/static/umap/js/modules/rules.js | 1 + .../tests/integration/test_conditional_rules.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index b065bc7a..f1d5d349 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -76,6 +76,7 @@ class Rule { this.key = vars[0] this.expected = vars[1] if (!isNaN(this.expected)) this.cast = parseFloat + else if (['true', 'false'].includes(this.expected)) this.cast = (v) => !!v this.expected = this.cast(this.expected) } diff --git a/umap/tests/integration/test_conditional_rules.py b/umap/tests/integration/test_conditional_rules.py index 6170cba9..484a0d0e 100644 --- a/umap/tests/integration/test_conditional_rules.py +++ b/umap/tests/integration/test_conditional_rules.py @@ -22,6 +22,7 @@ DATALAYER_DATA1 = { "mytype": "even", "name": "Point 2", "mynumber": 10, + "myboolean": True, "mydate": "2024/04/14 12:19:17", }, "geometry": {"type": "Point", "coordinates": [0.065918, 48.385442]}, @@ -32,6 +33,7 @@ DATALAYER_DATA1 = { "mytype": "odd", "name": "Point 1", "mynumber": 12, + "myboolean": False, "mydate": "2024/03/13 12:20:20", }, "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]}, @@ -52,6 +54,7 @@ DATALAYER_DATA2 = { "mytype": "even", "name": "Point 4", "mynumber": 10, + "myboolean": "true", "mydate": "2024/08/18 13:14:15", }, "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, @@ -143,6 +146,20 @@ def test_lt_rule_with_float_at_load(live_server, page, map): assert colors.count("rgb(240, 248, 255)") == 3 +def test_equal_rule_with_boolean_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "myboolean=true", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(4) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 2 + + def test_can_create_new_rule(live_server, page, openmap): DataLayerFactory(map=openmap, data=DATALAYER_DATA1) DataLayerFactory(map=openmap, data=DATALAYER_DATA2) From 05ea45acd293ee98ea329d8c89192a7b28e20d55 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 29 Apr 2024 15:57:41 +0200 Subject: [PATCH 09/14] wip: use getter/setter for Rule dynamic properties --- umap/static/umap/js/modules/rules.js | 44 ++++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index f1d5d349..96439622 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -3,32 +3,32 @@ import * as Utils from './utils.js' import { translate } from './i18n.js' class Rule { + #condition = null + + get condition() { + return this.#condition + } + + set condition(value) { + this.#condition = value + this.parse() + } + + #isDirty = false + + get isDirty() { + return this.#isDirty + } + + set isDirty(status) { + this.#isDirty = status + if (status) this.map.isDirty = status + } + constructor(map, condition = '', options = {}) { this.map = map this.active = true this.options = options - let isDirty = false - Object.defineProperty(this, 'isDirty', { - get: () => { - return isDirty - }, - set: (status) => { - isDirty = status - if (status) { - this.map.isDirty = status - } - }, - }) - let _condition - Object.defineProperty(this, 'condition', { - get: () => { - return _condition - }, - set: (value) => { - _condition = value - this.parse() - }, - }) this.condition = condition } From 907ba09c45cf3deb1bee301fdc98fb3cca412026 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 29 Apr 2024 17:32:38 +0200 Subject: [PATCH 10/14] wip: do not use private property yet Support is not ready for us. --- umap/static/umap/js/modules/rules.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index 96439622..7191d4cd 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -3,25 +3,25 @@ import * as Utils from './utils.js' import { translate } from './i18n.js' class Rule { - #condition = null + _condition = null get condition() { - return this.#condition + return this._condition } set condition(value) { - this.#condition = value + this._condition = value this.parse() } - #isDirty = false + _isDirty = false get isDirty() { - return this.#isDirty + return this._isDirty } set isDirty(status) { - this.#isDirty = status + this._isDirty = status if (status) this.map.isDirty = status } From f10d345113d666a425777b977acc70f914c71a58 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 29 Apr 2024 17:43:58 +0200 Subject: [PATCH 11/14] wip: use this.rules.getOption directly --- umap/static/umap/js/umap.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/umap/static/umap/js/umap.js b/umap/static/umap/js/umap.js index 13702036..e5fc94d4 100644 --- a/umap/static/umap/js/umap.js +++ b/umap/static/umap/js/umap.js @@ -824,13 +824,9 @@ U.Map = L.Map.extend({ return U.SCHEMA[option] && U.SCHEMA[option].default }, - getRuleOption: function (option, feature) { - return this.rules.getOption(option, feature) - }, - getOption: function (option, feature) { if (feature) { - const value = this.getRuleOption(option, feature) + const value = this.rules.getOption(option, feature) if (value !== undefined) return value } if (U.Utils.usableOption(this.options, option)) return this.options[option] From 6bdba1d0ed3028cbe8cb1202bd2c39f5c7e83608 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Mon, 29 Apr 2024 17:59:33 +0200 Subject: [PATCH 12/14] wip: do not use public class fields yet Browser support is not enough. --- umap/static/umap/js/modules/rules.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index 7191d4cd..e89d6a8f 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -3,7 +3,6 @@ import * as Utils from './utils.js' import { translate } from './i18n.js' class Rule { - _condition = null get condition() { return this._condition @@ -14,7 +13,6 @@ class Rule { this.parse() } - _isDirty = false get isDirty() { return this._isDirty @@ -26,6 +24,18 @@ class Rule { } constructor(map, condition = '', options = {}) { + // TODO make this public properties when browser coverage is ok + // cf https://caniuse.com/?search=public%20class%20field + this._condition = null + this._isDirty = false + this.OPERATORS = [ + ['>', this.gt], + ['<', this.lt], + // When sent by Django + ['<', this.lt], + ['!=', this.not_equal], + ['=', this.equal], + ] this.map = map this.active = true this.options = options @@ -52,15 +62,6 @@ class Rule { return other < this.expected } - OPERATORS = [ - ['>', this.gt], - ['<', this.lt], - // When sent by Django - ['<', this.lt], - ['!=', this.not_equal], - ['=', this.equal], - ] - parse() { let vars = [] this.cast = (v) => v From 9dc11ec9f926ac60ffdf9697a310b11370b78b06 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Tue, 14 May 2024 19:15:32 +0200 Subject: [PATCH 13/14] wip: fix tests after rebase on master --- umap/tests/integration/test_conditional_rules.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/umap/tests/integration/test_conditional_rules.py b/umap/tests/integration/test_conditional_rules.py index 484a0d0e..d0c9cc03 100644 --- a/umap/tests/integration/test_conditional_rules.py +++ b/umap/tests/integration/test_conditional_rules.py @@ -168,7 +168,7 @@ def test_can_create_new_rule(live_server, page, openmap): expect(markers).to_have_count(4) page.get_by_role("button", name="Edit").click() page.get_by_role("link", name="Map advanced properties").click() - page.get_by_role("heading", name="Conditional style rules").click() + page.get_by_text("Conditional style rules").click() page.get_by_role("button", name="Add rule").click() page.locator("input[name=condition]").click() page.locator("input[name=condition]").fill("mytype=odd") @@ -192,7 +192,7 @@ def test_can_deactive_rule_from_list(live_server, page, openmap): assert colors.count("rgb(240, 248, 255)") == 2 page.get_by_role("button", name="Edit").click() page.get_by_role("link", name="Map advanced properties").click() - page.get_by_role("heading", name="Conditional style rules").click() + page.get_by_text("Conditional style rules").click() page.get_by_role("button", name="Show/hide layer").click() colors = getColors(markers) assert colors.count("rgb(240, 248, 255)") == 0 From c7541c19318ea4ea29212222816e70727b0dc9ef Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Thu, 30 May 2024 19:52:09 +0200 Subject: [PATCH 14/14] wip: add FAQ entry for conditional rules syntax --- docs-users/fr/support/faq.md | 12 +++++++++++- docs-users/support/faq.md | 10 +++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs-users/fr/support/faq.md b/docs-users/fr/support/faq.md index b1936727..55470464 100644 --- a/docs-users/fr/support/faq.md +++ b/docs-users/fr/support/faq.md @@ -1,6 +1,6 @@ # Questions Fréquemment Posées (FAQ) -## Quelle syntaxe est acceptée dans les champs de description ? +## Quelle syntaxe est acceptée dans les champs de description ? {: #text-formatting } * `*simple astérisque pour italique*` → *simple astérisque pour italique* * `**double astérisque pour gras**` → **double astérisque pour gras** @@ -37,3 +37,13 @@ Sur MacOS, utliser `Cmd` à la place de `Ctrl`. * `Ctrl+-` → dézoome * `Shift+click` sur un élément → ouvre le panneau d'édition de cet élément * `Ctrl+Shift+click` sur un élément → ouvre le panneau d'édition du calque de cet élément + +## Quelle syntaxe est acceptée dans les règles de formattage conditionnel ? {: #conditional-rules } + +* `macolonne=impair` → cible les éléments dont la colonne `macolonne` vaut `impair` +* `macolonne!=impair` → cible les éléments dont la colonne `macolonne` est absente ou dont la valeur est différente de `impair` +* `macolonne>12` → cible les éléments dont la colonne `macolonne` est supérieur au nombre `12` +* `macolonne<12.34` → cible les éléments dont la colonne `macolonne` est inférieure au nombre `12.34` + +Quand la condition est vraie pour un élément donné, le style associé sera appliqué. + diff --git a/docs-users/support/faq.md b/docs-users/support/faq.md index fd979517..9c61696a 100644 --- a/docs-users/support/faq.md +++ b/docs-users/support/faq.md @@ -1,6 +1,6 @@ # Frequently Asked Questions (FAQ) -## Which syntax is allowed in description fields? +## Which syntax is allowed in description fields? {: #text-formatting } * `*single star for italic*` → *single star for italic* * `**double star for bold**` → **double star for bold** @@ -38,3 +38,11 @@ In MacOS, `Ctrl` is replaced by `Cmd`. * `Shift+click` on a feature → edit this feature * `Ctrl+Shift+click` on a feature → edit this feature layer +## Which syntax is allowed in conditional rules? {: #conditional-rules } + +* `mycolumn=odd` → will match features whose column `mycolumn` equal `odd` +* `mycolumn!=odd` → will match features whose column `mycolumn` is missing or different from `odd` +* `mycolumn>12` → will match features whose column `mycolumn` is greater than `12` (as number) +* `mycolumn<12.34` → will match features whose column `mycolumn` is lower than `12.34` (as number) + +When the condition match, the associated style will be applied to corresponding feature.