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. 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..e89d6a8f --- /dev/null +++ b/umap/static/umap/js/modules/rules.js @@ -0,0 +1,241 @@ +import { DomUtil, DomEvent, stamp } from '../../vendors/leaflet/leaflet-src.esm.js' +import * as Utils from './utils.js' +import { translate } from './i18n.js' + +class Rule { + + get condition() { + return this._condition + } + + set condition(value) { + this._condition = value + this.parse() + } + + + get isDirty() { + return this._isDirty + } + + set isDirty(status) { + this._isDirty = status + if (status) this.map.isDirty = status + } + + 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 + this.condition = condition + } + + render(fields) { + 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 + } + + parse() { + let vars = [] + 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) return + 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) + } + + match(props) { + if (!this.operator || !this.active) return false + return this.operator(this.cast(props[this.key])) + } + + getMap() { + return this.map + } + + getOption(option) { + return this.options[option] + } + + edit() { + const options = [ + [ + 'condition', + { + handler: 'BlurInput', + label: translate('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 = DomUtil.add('div', '', container) + defaultShapeProperties.appendChild(builder.build()) + + this.map.editPanel.open({ content: container }) + } + + renderToolbox(row) { + row.classList.toggle('off', !this.active) + 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', + translate('Delete layer') + ) + DomEvent.on(edit, 'click', this.edit, this) + DomEvent.on( + remove, + 'click', + function () { + 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')) + DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder')) + row.dataset.id = stamp(this) + DomEvent.on(toggle, 'click', () => { + this.active = !this.active + row.classList.toggle('off', !this.active) + this.map.render(['rules']) + }) + } + + _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)) + } + } + + 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 = DomUtil.createFieldset(container, translate('Conditional style rules')) + if (this.rules.length) { + const ul = DomUtil.create('ul', '', body) + for (const rule of this.rules) { + rule.renderToolbox(DomUtil.create('li', 'orderable', ul)) + } + + const orderable = new U.Orderable(ul, this.onReorder.bind(this)) + } + + 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 93ab6ae3..b6b0061e 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'], @@ -386,6 +390,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 2601b1fb..68e63f99 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,11 @@ U.Map = L.Map.extend({ return U.SCHEMA[option] && U.SCHEMA[option].default }, - getOption: function (option) { + getOption: function (option, feature) { + if (feature) { + const value = this.rules.getOption(option, feature) + if (value !== undefined) return value + } if (U.Utils.usableOption(this.options, option)) return this.options[option] return this.getDefaultOption(option) }, @@ -1031,6 +1036,7 @@ U.Map = L.Map.extend({ }, saveSelf: async function () { + this.rules.commit() const geojson = { type: 'Feature', geometry: this.geometry(), @@ -1558,6 +1564,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) } }, diff --git a/umap/tests/integration/test_conditional_rules.py b/umap/tests/integration/test_conditional_rules.py new file mode 100644 index 00000000..d0c9cc03 --- /dev/null +++ b/umap/tests/integration/test_conditional_rules.py @@ -0,0 +1,201 @@ +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, + "myboolean": True, + "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, + "myboolean": False, + "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, + "myboolean": "true", + "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_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_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_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_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) + 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_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") + 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_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 + page.get_by_role("button", name="Show/hide layer").click() + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 2