From f30ee0f5d3f7db5195262f354f9b5d8ccef5cfe5 Mon Sep 17 00:00:00 2001 From: Yohan Boniface Date: Tue, 28 Nov 2023 14:19:47 +0100 Subject: [PATCH] (WIP) Add Bubbles layer type fix #1399 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a very first look. But it's not as simple as I thought, because: - as for a heatmap, we need a special representation (each feature should be a circle, not a normal icon nor a polygon) - as for a choropleth, we want to dynamically take control over the rendering (we need all the features to be able to compute the bubbles sizes) - as for a normal layer, we want all the features to react to interaction, specifically click to open a popup, but also tooltip, highlight (?), etc. In this first implementation, I've created a new type of layer, that creates a CircleMarker for each feature, but doing so we lose all the interactions. I've tried to set a sort of proxy so that a click on the circle will fire a click on the feature, but as this feature is actually not on the map, this does not work naturally. Also, at this point, the styling is only done once, so editing layer's style will not be updated live in the map. A few more thinking is needed, mainly to decide how to handle the circle: should it be a "proxy layer" as now, or should we try to transform a feature on the fly (make sure it is a marker of type circle, but as for now our circle are html, not svg…), and in this case, should we magically handle polygons and polylines (taking their center), or only markers (which could be easier, and possibly enough) ? --- umap/static/umap/js/umap.forms.js | 1 + umap/static/umap/js/umap.layer.js | 117 ++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 2aa84e768..18ca3cbe4 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -381,6 +381,7 @@ L.FormBuilder.LayerTypeChooser = L.FormBuilder.Select.extend({ ['Cluster', L._('Clustered')], ['Heat', L._('Heatmap')], ['Choropleth', L._('Choropleth')], + ['Bubble', L._('Bubbles')], ], }) diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index ecc50829d..0ac905857 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -116,6 +116,123 @@ L.U.Layer.Cluster = L.MarkerClusterGroup.extend({ }, }) +L.U.Layer.Bubble = L.FeatureGroup.extend({ + _type: 'Bubble', + includes: [L.U.Layer], + canBrowse: true, + + initialize: function (datalayer) { + this.datalayer = datalayer + if (!L.Util.isObject(this.datalayer.options.bubbles)) { + this.datalayer.options.bubbles = { + minSize: 1, + maxSize: 30 + } + } + L.FeatureGroup.prototype.initialize.call(this) + this.datalayer.onceDataLoaded(() => { + this.redraw() + this.datalayer.on('datachanged', this.redraw, this) + }) + }, + + redraw: function () { + this.computeMinMax() + if (this._map) { + this.eachLayer((circle) => { + circle.setRadius(this.computeRadius(circle.options.value)) + this._map.addLayer(circle) + }) + } + }, + + computeRadius: function (value) { + const minSize = this.datalayer.options.bubbles.minSize || 1, + maxSize = this.datalayer.options.bubbles.maxSize || 30 + return minSize + ((maxSize - minSize) / (this.options.max - this.options.min)) * Math.sqrt(value) + }, + + _getValue: function (feature) { + const key = this.datalayer.options.bubbles.property || 'value' + return +feature.properties[key] // TODO: should we catch values non castable to int ? + }, + + computeMinMax: function () { + const values = [] + this.datalayer.eachLayer((layer) => { + let value = this._getValue(layer) + if (!isNaN(value)) values.push(Math.sqrt(value)) + }) + if (!values.length) { + this.options.min = 0 + this.options.max = 0 + return + } + this.options.min = Math.min(...values) + this.options.max = Math.max(...values) + }, + + addLayer: function (layer) { + // Do not add yet the layer to the map + // wait for datachanged event, so we can compute breaks once + const value = this._getValue(layer) + if (isNaN(value)) return this + const circle = L.circleMarker(layer.getCenter(), { + value: value, + radius: this.computeRadius(value), + weight: this.datalayer.getOption('weight'), + color: this.datalayer.getOption('color'), + fillColor: this.datalayer.getOption('fillColor'), + }) + let id = this.getLayerId(circle) + this._layers[id] = circle + // Needed for the popup to open on the map + layer._map = this._map + circle.on('click', layer.view, layer) + return this + }, + + onAdd: function (map) { + this.computeMinMax() + L.FeatureGroup.prototype.onAdd.call(this, map) + }, + + getEditableOptions: function () { + return [ + [ + 'options.bubbles.property', + { + handler: 'Select', + selectOptions: this.datalayer._propertiesIndex, + label: L._('Bubbles map property value'), + }, + ], + [ + 'options.bubbles.minSize', + { + handler: 'Range', + min: 1, + max: 10, + step: 1, + label: L._('Mininum size of the bubble'), + helpText: L._('A size in pixel'), + }, + ], + [ + 'options.bubbles.maxSize', + { + handler: 'Range', + min: 30, + max: 100, + step: 5, + label: L._('Maximum size of the bubble'), + helpText: L._('A size in pixel'), + }, + ], + ] + }, +}) + L.U.Layer.Choropleth = L.FeatureGroup.extend({ _type: 'Choropleth', includes: [L.U.Layer],