diff --git a/package.json b/package.json index b2995e06..33d3835c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "homepage": "http://wiki.openstreetmap.org/wiki/UMap", "dependencies": { "@tmcw/togeojson": "^5.8.0", + "colorbrewer": "^1.5.6", "csv2geojson": "5.1.1", "dompurify": "^3.0.3", "georsstogeojson": "^0.1.0", @@ -55,6 +56,7 @@ "leaflet.path.drag": "0.0.6", "leaflet.photon": "0.8.0", "osmtogeojson": "^3.0.0-beta.3", + "simple-statistics": "^7.8.3", "togpx": "^0.5.4", "tokml": "0.4.0" } diff --git a/scripts/vendorsjs.sh b/scripts/vendorsjs.sh index 751132fc..793fbeb5 100755 --- a/scripts/vendorsjs.sh +++ b/scripts/vendorsjs.sh @@ -26,5 +26,7 @@ mkdir -p umap/static/umap/vendors/tokml && cp -r node_modules/tokml/tokml.js uma mkdir -p umap/static/umap/vendors/locatecontrol/ && cp -r node_modules/leaflet.locatecontrol/dist/L.Control.Locate.css umap/static/umap/vendors/locatecontrol/ mkdir -p umap/static/umap/vendors/locatecontrol/ && cp -r node_modules/leaflet.locatecontrol/src/L.Control.Locate.js umap/static/umap/vendors/locatecontrol/ mkdir -p umap/static/umap/vendors/dompurify/ && cp -r node_modules/dompurify/dist/purify.js umap/static/umap/vendors/dompurify/ +mkdir -p umap/static/umap/vendors/colorbrewer/ && cp node_modules/colorbrewer/index.js umap/static/umap/vendors/colorbrewer/colorbrewer.js +mkdir -p umap/static/umap/vendors/simple-statistics/ && cp node_modules/simple-statistics/dist/simple-statistics.min.js umap/static/umap/vendors/simple-statistics/ echo 'Done!' diff --git a/umap/static/umap/base.css b/umap/static/umap/base.css index b5f1ce9a..12cd7d39 100644 --- a/umap/static/umap/base.css +++ b/umap/static/umap/base.css @@ -354,7 +354,8 @@ input.switch:checked ~ label:after { .button-bar.half { grid-template-columns: 1fr 1fr; } -.umap-multiplechoice.by3 { +.umap-multiplechoice.by3, +.umap-multiplechoice.by5 { grid-template-columns: 1fr 1fr 1fr; } .umap-multiplechoice.by4 { diff --git a/umap/static/umap/js/umap.controls.js b/umap/static/umap/js/umap.controls.js index 78565975..62e0d83a 100644 --- a/umap/static/umap/js/umap.controls.js +++ b/umap/static/umap/js/umap.controls.js @@ -598,6 +598,7 @@ L.U.DataLayersControl = L.Control.extend({ L.U.DataLayer.include({ renderLegend: function (container) { + if (this.layer.renderLegend) return this.layer.renderLegend(container) const color = L.DomUtil.create('span', 'datalayer-color', container) color.style.backgroundColor = this.getColor() }, diff --git a/umap/static/umap/js/umap.features.js b/umap/static/umap/js/umap.features.js index 7de50be8..5b3b8f7b 100644 --- a/umap/static/umap/js/umap.features.js +++ b/umap/static/umap/js/umap.features.js @@ -1,5 +1,5 @@ L.U.FeatureMixin = { - staticOptions: {}, + staticOptions: { mainColor: 'color' }, initialize: function (map, latlng, options) { this.map = map @@ -283,7 +283,7 @@ L.U.FeatureMixin = { } else if (L.Util.usableOption(this.properties._umap_options, option)) { value = this.properties._umap_options[option] } else if (this.datalayer) { - value = this.datalayer.getOption(option) + value = this.datalayer.getOption(option, this) } else { value = this.map.getOption(option) } @@ -948,6 +948,7 @@ L.U.Polyline = L.Polyline.extend({ staticOptions: { stroke: true, fill: false, + mainColor: 'color', }, isSameClass: function (other) { @@ -1084,6 +1085,9 @@ L.U.Polyline = L.Polyline.extend({ L.U.Polygon = L.Polygon.extend({ parentClass: L.Polygon, includes: [L.U.FeatureMixin, L.U.PathMixin], + staticOptions: { + mainColor: 'fillColor', + }, isSameClass: function (other) { return other instanceof L.U.Polygon diff --git a/umap/static/umap/js/umap.forms.js b/umap/static/umap/js/umap.forms.js index 6faf6531..c75d01ec 100644 --- a/umap/static/umap/js/umap.forms.js +++ b/umap/static/umap/js/umap.forms.js @@ -379,6 +379,7 @@ L.FormBuilder.LayerTypeChooser = L.FormBuilder.Select.extend({ ['Default', L._('Default')], ['Cluster', L._('Clustered')], ['Heat', L._('Heatmap')], + ['Choropleth', L._('Choropleth')], ], }) @@ -732,7 +733,7 @@ L.FormBuilder.MultiChoice = L.FormBuilder.Element.extend({ fetch: function () { let value = (this.backup = this.toHTML()) if (!this.container.querySelector(`input[type="radio"][value="${value}"]`)) - value = this.default + value = typeof(this.options.default) !== 'undefined' ? this.options.default : this.default this.container.querySelector(`input[type="radio"][value="${value}"]`).checked = true }, diff --git a/umap/static/umap/js/umap.layer.js b/umap/static/umap/js/umap.layer.js index 790b0143..9525c217 100644 --- a/umap/static/umap/js/umap.layer.js +++ b/umap/static/umap/js/umap.layer.js @@ -106,6 +106,194 @@ L.U.Layer.Cluster = L.MarkerClusterGroup.extend({ }, }) +L.U.Layer.Choropleth = L.FeatureGroup.extend({ + _type: 'Choropleth', + includes: [L.U.Layer], + canBrowse: true, + // Have defaults that better suit the choropleth mode. + defaults: { + color: 'white', + fillColor: 'red', + fillOpacity: 0.7, + weight: 2, + }, + MODES: { + kmeans: L._('K-means'), + equidistant: L._('Equidistant'), + jenks: L._('Jenks-Fisher'), + quantiles: L._('Quantiles'), + manual: L._('Manual'), + }, + + initialize: function (datalayer) { + this.datalayer = datalayer + if (!L.Util.isObject(this.datalayer.options.choropleth)) { + this.datalayer.options.choropleth = {} + } + L.FeatureGroup.prototype.initialize.call( + this, + [], + this.datalayer.options.choropleth + ) + this.datalayer.on('datachanged', this.redraw, this) + }, + + redraw: function () { + this.computeBreaks() + if (this._map) this.eachLayer(this._map.addLayer, this._map) + }, + + _getValue: function (feature) { + const key = this.datalayer.options.choropleth.property || 'value' + return +feature.properties[key] // TODO: should we catch values non castable to int ? + }, + + computeBreaks: function () { + const values = [] + this.datalayer.eachLayer((layer) => { + let value = this._getValue(layer) + if (!isNaN(value)) values.push(value) + }) + if (!values.length) { + this.options.breaks = [] + this.options.colors = [] + return + } + let mode = this.datalayer.options.choropleth.mode, + classes = +this.datalayer.options.choropleth.classes || 5, + breaks = [] + if (mode === 'manual') { + const manualBreaks = this.datalayer.options.choropleth.breaks + if (manualBreaks) { + breaks = manualBreaks.split(",").map(b => +b).filter(b => !isNaN(b)) + } + } else if (mode === 'equidistant') { + breaks = ss.equalIntervalBreaks(values, classes) + } else if (mode === 'jenks') { + breaks = ss.jenks(values, classes) + } else if (mode === 'quantiles') { + const quantiles = [...Array(classes)].map((e, i) => i/classes).concat(1) + breaks = ss.quantile(values, quantiles) + } else { + breaks = ss.ckmeans(values, classes).map((cluster) => cluster[0]) + breaks.push(ss.max(values)) // Needed for computing the legend + } + this.options.breaks = breaks + this.datalayer.options.choropleth.breaks = this.options.breaks.map(b => +b.toFixed(2)).join(',') + const fillColor = this.datalayer.getOption('fillColor') || this.defaults.fillColor + let colorScheme = this.datalayer.options.choropleth.brewer + if (!colorbrewer[colorScheme]) colorScheme = 'Blues' + this.options.colors = colorbrewer[colorScheme][breaks.length - 1] || [] + }, + + getColor: function (feature) { + if (!feature) return // FIXME shold not happen + const featureValue = this._getValue(feature) + // Find the bucket/step/limit that this value is less than and give it that color + for (let i = 1; i < this.options.breaks.length; i++) { + if (featureValue <= this.options.breaks[i]) { + return this.options.colors[i - 1] + } + } + }, + + getOption: function (option, feature) { + if (feature && option === feature.staticOptions.mainColor) return this.getColor(feature) + }, + + addLayer: function (layer) { + // Do not add yet the layer to the map + // wait for datachanged event, so we want compute breaks once + var id = this.getLayerId(layer) + this._layers[id] = layer + return this + }, + + onAdd: function (map) { + this.computeBreaks() + L.FeatureGroup.prototype.onAdd.call(this, map) + }, + + postUpdate: function (e) { + if (e.helper.field === 'options.choropleth.breaks') { + this.datalayer.options.choropleth.mode = 'manual' + e.helper.builder.helpers["options.choropleth.mode"].fetch() + } + this.computeBreaks() + if (e.helper.field !== 'options.choropleth.breaks') { + e.helper.builder.helpers["options.choropleth.breaks"].fetch() + } + }, + + getEditableOptions: function () { + const brewerSchemes = Object.keys(colorbrewer) + .filter((k) => k !== 'schemeGroups') + .sort() + + return [ + [ + 'options.choropleth.property', + { + handler: 'Select', + selectOptions: this.datalayer._propertiesIndex, + label: L._('Choropleth property value'), + }, + ], + [ + 'options.choropleth.brewer', + { + handler: 'Select', + label: L._('Choropleth color palette'), + selectOptions: brewerSchemes, + }, + ], + [ + 'options.choropleth.classes', + { + handler: 'Range', + min: 3, + max: 9, + step: 1, + label: L._('Choropleth classes'), + helpText: L._('Number of desired classes (default 5)'), + }, + ], + [ + 'options.choropleth.breaks', + { + handler: 'BlurInput', + label: L._('Choropleth breakpoints'), + helpText: L._('Comma separated list of numbers, including min and max values.'), + }, + ], + [ + 'options.choropleth.mode', + { + handler: 'MultiChoice', + default: 'kmeans', + choices: Object.entries(this.MODES), + label: L._('Choropleth mode'), + }, + ], + ] + }, + + renderLegend: function (container) { + const parent = L.DomUtil.create('ul', '', container) + let li, color, label + + this.options.breaks.slice(0, -1).forEach((limit, index) => { + li = L.DomUtil.create('li', '', parent) + color = L.DomUtil.create('span', 'datalayer-color', li) + color.style.backgroundColor = this.options.colors[index] + label = L.DomUtil.create('span', '', li) + label.textContent = `${+this.options.breaks[index].toFixed( + 1 + )} - ${+this.options.breaks[index + 1].toFixed(1)}` + }) + }, +}) + L.U.Layer.Heat = L.HeatLayer.extend({ _type: 'Heat', includes: [L.U.Layer], @@ -399,7 +587,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) - this.eachLayer((feature) => this.showFeature(feature)) + this.eachLayer(this.showFeature) if (visible) this.show() this.propagateRemote() }, @@ -970,11 +1158,9 @@ L.U.DataLayer = L.Evented.extend({ 'options.fillOpacity', ] - shapeOptions = shapeOptions.concat(this.layer.getEditableOptions()) - - const redrawCallback = function (field) { + const redrawCallback = function (e) { this.hide() - this.layer.postUpdate(field) + this.layer.postUpdate(e) this.show() } @@ -1123,9 +1309,22 @@ L.U.DataLayer = L.Evented.extend({ this.map.ui.openPanel({ data: { html: container }, className: 'dark' }) }, - getOption: function (option) { + getOwnOption: function (option) { if (L.Util.usableOption(this.options, option)) return this.options[option] - else return this.map.getOption(option) + }, + + getOption: function (option, feature) { + if (this.layer && this.layer.getOption) { + const value = this.layer.getOption(option, feature) + if (typeof value !== 'undefined') return value + } + if (typeof this.getOwnOption(option) !== 'undefined') { + return this.getOwnOption(option) + } else if (this.layer && this.layer.defaults && this.layer.defaults[option]) { + return this.layer.defaults[option] + } else { + return this.map.getOption(option) + } }, buildVersionsFieldset: function (container) { diff --git a/umap/static/umap/map.css b/umap/static/umap/map.css index 2f51dc01..483316e9 100644 --- a/umap/static/umap/map.css +++ b/umap/static/umap/map.css @@ -1087,6 +1087,17 @@ a.add-datalayer:hover, vertical-align: middle; } +.datalayer-legend { + color: #555; + padding: 6px 8px; + box-shadow: 0 0 3px rgba(0,0,0,0.2); + border-radius: 1px; +} +.datalayer-legend ul { + list-style-type: none; + padding: 0; + margin: 0; +} /* ********************************* */ /* Popup */ diff --git a/umap/static/umap/test/Choropleth.js b/umap/static/umap/test/Choropleth.js new file mode 100644 index 00000000..f87e7cce --- /dev/null +++ b/umap/static/umap/test/Choropleth.js @@ -0,0 +1,243 @@ +const POLYGONS = { + _umap_options: defaultDatalayerData(), + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + name: 'number 1', + value: 45, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 49], + [-2, 47], + [1, 46], + [3, 47], + [0, 49], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + name: 'number 2', + value: 87, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 49], + [2, 50], + [6, 49], + [4, 47], + [0, 49], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + name: 'number 3', + value: 673, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [4, 47], + [6, 49], + [11, 47], + [9, 45], + [4, 47], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + name: 'number 4', + value: 674, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [2, 46], + [4, 47], + [8, 45], + [6, 43], + [2, 46], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + name: 'number 5', + value: 839, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-2, 47], + [1, 46], + [0, 44], + [-4, 45], + [-2, 47], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + name: 'number 6', + value: 3829, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [1, 45], + [5, 43], + [4, 42], + [0, 44], + [1, 45], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + name: 'number 7', + value: 4900, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [9, 45], + [12, 47], + [15, 45], + [13, 43], + [9, 45], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + name: 'number 8', + value: 4988, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [7, 43], + [9, 45], + [12, 43], + [10, 42], + [7, 43], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + name: 'number 9', + value: 9898, + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [4, 42], + [6, 43], + [9, 41], + [7, 40], + [4, 42], + ], + ], + }, + }, + ], +} + +describe('L.U.Choropleth', function () { + let path = '/map/99/datalayer/edit/62/', + poly1, + poly4, + poly9 + + before(function () { + this.server = sinon.fakeServer.create() + this.server.respondWith(/\/datalayer\/62\/\?.*/, JSON.stringify(POLYGONS)) + this.map = initMap({ umap_id: 99 }) + this.datalayer = this.map.getDataLayerByUmapId(62) + this.server.respond() + this.datalayer.options.type = 'Choropleth' + this.datalayer.options.choropleth = { + property: 'value', + } + enableEdit() + this.datalayer.eachLayer(function (layer) { + if (layer.properties.name === 'number 1') { + poly1 = layer + } else if (layer.properties.name === 'number 4') { + poly4 = layer + } else if (layer.properties.name === 'number 9') { + poly9 = layer + } + }) + }) + after(function () { + this.server.restore() + //resetMap() + }) + + describe('#init()', function () { + it('datalayer should have 9 features', function () { + assert.equal(this.datalayer._index.length, 9) + }) + }) + describe('#compute()', function () { + it('choropleth should compute default colors', function () { + this.datalayer.resetLayer(true) + assert.deepEqual( + this.datalayer.layer.options.breaks, + [45, 673, 3829, 4900, 9898, 9898] + ) + assert.equal(poly1._path.attributes.fill.value, '#eff3ff') + assert.equal(poly4._path.attributes.fill.value, '#bdd7e7') + assert.equal(poly9._path.attributes.fill.value, '#3182bd') + }) + it('can change brewer scheme', function () { + this.datalayer.options.choropleth.brewer = 'Reds' + this.datalayer.resetLayer(true) + assert.equal(poly1._path.attributes.fill.value, '#fee5d9') + assert.equal(poly4._path.attributes.fill.value, '#fcae91') + assert.equal(poly9._path.attributes.fill.value, '#de2d26') + }) + it('choropleth should allow to change steps', function () { + this.datalayer.options.choropleth.brewer = 'Blues' + this.datalayer.options.choropleth.classes = 6 + this.datalayer.resetLayer(true) + assert.equal(poly1._path.attributes.fill.value, '#eff3ff') + assert.equal(poly4._path.attributes.fill.value, '#c6dbef') + assert.equal(poly9._path.attributes.fill.value, '#3182bd') + }) + }) +}) diff --git a/umap/static/umap/test/index.html b/umap/static/umap/test/index.html index 620d0274..e56ff9b5 100644 --- a/umap/static/umap/test/index.html +++ b/umap/static/umap/test/index.html @@ -25,6 +25,8 @@ + + @@ -82,6 +84,7 @@ +